Initial commit - Video Service for Coolify

This commit is contained in:
2025-12-17 10:07:44 +01:00
commit baa1675738
46 changed files with 2386 additions and 0 deletions

1
app/api/__init__.py Executable file
View File

@@ -0,0 +1 @@
# API Module

127
app/api/auth.py Executable file
View File

@@ -0,0 +1,127 @@
"""
Authentication Module
- API Key for WordPress ↔ Video-Service communication
- JWT for streaming token validation
"""
from datetime import UTC, datetime, timedelta
from typing import Annotated
import jwt
from fastapi import Depends, Header, HTTPException, Request, status
from pydantic import BaseModel
from app.config import Settings, get_settings
class TokenPayload(BaseModel):
"""JWT token payload for video streaming."""
video_id: str
buchung_id: int
ip: str
exp: datetime
class StreamToken(BaseModel):
"""Response model for stream token."""
token: str
expires_at: datetime
stream_url: str
def verify_api_key(
x_api_key: Annotated[str | None, Header()] = None,
settings: Settings = Depends(get_settings),
) -> bool:
"""Verify API key from WordPress."""
if not x_api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key",
)
if x_api_key != settings.api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
return True
def create_stream_token(
video_id: str,
buchung_id: int,
client_ip: str,
settings: Settings = Depends(get_settings),
) -> StreamToken:
"""Create a JWT token for video streaming with IP binding."""
expiry = datetime.now(UTC) + timedelta(hours=settings.jwt_expiry_hours)
payload = {
"video_id": video_id,
"buchung_id": buchung_id,
"ip": client_ip,
"exp": expiry,
"iat": datetime.now(UTC),
}
token = jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
# Build stream URL using configured base_url (handles production/development)
stream_url = (
f"{settings.base_url}/api/v1/stream/{video_id}/master.m3u8?token={token}"
)
return StreamToken(
token=token,
expires_at=expiry,
stream_url=stream_url,
)
def verify_stream_token(
token: str,
video_id: str,
request: Request,
settings: Settings = Depends(get_settings),
) -> TokenPayload:
"""Verify JWT token for streaming, including IP binding."""
try:
payload = jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
except jwt.ExpiredSignatureError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
) from e
except jwt.InvalidTokenError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {e}",
) from e
# Verify video_id matches
if payload.get("video_id") != video_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Token not valid for this video",
)
# Verify IP binding (optional in development)
client_ip = request.client.host if request.client else "unknown"
if settings.environment == "production" and payload.get("ip") != client_ip:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="IP address mismatch",
)
return TokenPayload(
video_id=payload["video_id"],
buchung_id=payload["buchung_id"],
ip=payload["ip"],
exp=datetime.fromtimestamp(payload["exp"], tz=UTC),
)

458
app/api/routes.py Executable file
View File

@@ -0,0 +1,458 @@
"""
API Routes for Video-Service
"""
import logging
import re
from datetime import UTC
from pathlib import Path
import redis
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Query,
Request,
UploadFile,
status,
)
from fastapi.responses import FileResponse, Response
from app.api.auth import create_stream_token, verify_api_key, verify_stream_token
from app.config import Settings, get_settings
from app.models.schemas import (
HealthResponse,
StreamTokenRequest,
VideoListResponse,
VideoMetadata,
VideoQuality,
VideoStatus,
VideoStatusResponse,
)
from app.services.converter import ConverterService
from app.services.storage import StorageService
from app.tasks.video_tasks import process_video
logger = logging.getLogger(__name__)
router = APIRouter()
# Health Check (no auth required)
@router.get("/health", response_model=HealthResponse)
async def health_check(settings: Settings = Depends(get_settings)):
"""Health check endpoint."""
storage = StorageService(settings)
converter = ConverterService(settings)
# Check Redis connection
redis_connected = False
try:
r = redis.from_url(settings.redis_url)
r.ping()
redis_connected = True
except Exception:
pass
from app import __version__
return HealthResponse(
status="healthy" if redis_connected and storage.is_writable() else "degraded",
version=__version__,
environment=settings.environment,
redis_connected=redis_connected,
storage_writable=storage.is_writable(),
ffmpeg_available=converter.is_ffmpeg_available(),
)
# Video Upload
@router.post("/videos/upload", response_model=VideoStatusResponse)
async def upload_video(
file: UploadFile = File(...),
kurs_id: int = Form(...),
title: str = Form(...),
description: str | None = Form(None),
_: bool = Depends(verify_api_key),
settings: Settings = Depends(get_settings),
):
"""
Upload a video file for processing.
Requires API key authentication.
"""
# Validate file type
allowed_types = [
"video/mp4",
"video/quicktime",
"video/x-msvideo",
"video/x-matroska",
]
if file.content_type not in allowed_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type: {file.content_type}. Allowed: MP4, MOV, AVI, MKV",
)
# Check file size (read first chunk to estimate)
storage = StorageService(settings)
video_id = storage.generate_video_id()
# Determine safe filename
original_ext = Path(file.filename or "video.mp4").suffix.lower()
if original_ext not in [".mp4", ".mov", ".avi", ".mkv"]:
original_ext = ".mp4"
safe_filename = f"source{original_ext}"
# Save uploaded file
upload_path = storage.get_upload_path(video_id, safe_filename)
try:
# Stream file to disk
file_size = 0
with upload_path.open("wb") as f:
while chunk := await file.read(1024 * 1024): # 1MB chunks
file_size += len(chunk)
if file_size > settings.max_upload_size_bytes:
f.close()
upload_path.unlink()
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large. Max: {settings.max_upload_size_mb}MB",
)
f.write(chunk)
logger.info(f"Uploaded {file_size} bytes for video {video_id}")
# Save initial metadata
from datetime import datetime
storage.save_metadata(
video_id=video_id,
kurs_id=kurs_id,
title=title,
status=VideoStatus.PENDING,
)
# Queue processing task
process_video.delay(video_id, safe_filename)
# Return status
return VideoStatusResponse(
video_id=video_id,
kurs_id=kurs_id,
title=title,
status=VideoStatus.PENDING,
progress=0,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Upload failed: {e}")
if upload_path.exists():
upload_path.unlink()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Upload failed: {e!s}",
) from e
# Video Status
@router.get("/videos/{video_id}/status", response_model=VideoStatusResponse)
async def get_video_status(
video_id: str,
_: bool = Depends(verify_api_key),
settings: Settings = Depends(get_settings),
):
"""Get video processing status."""
storage = StorageService(settings)
meta = storage.load_metadata(video_id)
if not meta:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Video not found",
)
from datetime import datetime
# Parse metadata
video_metadata = None
if meta.get("metadata"):
video_metadata = VideoMetadata(**meta["metadata"])
# Build thumbnail URL using configured base_url
thumbnail_url = None
thumbnail_path = storage.get_thumbnail_path(video_id)
if thumbnail_path.exists():
thumbnail_url = f"{settings.base_url}/api/v1/videos/{video_id}/thumbnail"
# Determine available qualities
hls_path = storage.get_hls_path(video_id)
available_qualities = []
if hls_path.exists():
for quality in ["360p", "720p", "1080p"]:
if (hls_path / quality).exists():
available_qualities.append(VideoQuality(quality))
return VideoStatusResponse(
video_id=video_id,
kurs_id=meta.get("kurs_id", 0),
title=meta.get("title", ""),
status=VideoStatus(meta.get("status", "pending")),
progress=meta.get("progress", 0),
metadata=video_metadata,
thumbnail_url=thumbnail_url,
available_qualities=available_qualities,
created_at=datetime.fromisoformat(
meta.get("created_at", datetime.now().isoformat())
),
updated_at=datetime.fromisoformat(
meta.get("updated_at", datetime.now().isoformat())
),
error_message=meta.get("error_message"),
)
# List Videos
@router.get("/videos", response_model=VideoListResponse)
async def list_videos(
kurs_id: int | None = Query(None),
status_filter: VideoStatus | None = Query(None, alias="status"),
_: bool = Depends(verify_api_key),
settings: Settings = Depends(get_settings),
):
"""List all videos, optionally filtered by kurs_id or status."""
storage = StorageService(settings)
videos = []
for vid in storage.get_video_ids():
meta = storage.load_metadata(vid)
if not meta:
continue
# Apply filters
if kurs_id and meta.get("kurs_id") != kurs_id:
continue
if status_filter and meta.get("status") != status_filter.value:
continue
from datetime import datetime
video_metadata = None
if meta.get("metadata"):
video_metadata = VideoMetadata(**meta["metadata"])
videos.append(
VideoStatusResponse(
video_id=vid,
kurs_id=meta.get("kurs_id", 0),
title=meta.get("title", ""),
status=VideoStatus(meta.get("status", "pending")),
progress=meta.get("progress", 0),
metadata=video_metadata,
created_at=datetime.fromisoformat(
meta.get("created_at", datetime.now().isoformat())
),
updated_at=datetime.fromisoformat(
meta.get("updated_at", datetime.now().isoformat())
),
error_message=meta.get("error_message"),
)
)
return VideoListResponse(videos=videos, total=len(videos))
# Delete Video
@router.delete("/videos/{video_id}")
async def delete_video(
video_id: str,
_: bool = Depends(verify_api_key),
settings: Settings = Depends(get_settings),
):
"""Delete a video and all associated files."""
storage = StorageService(settings)
if not storage.delete_video(video_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Video not found",
)
return {"status": "deleted", "video_id": video_id}
# Thumbnail
@router.get("/videos/{video_id}/thumbnail")
async def get_thumbnail(
video_id: str,
settings: Settings = Depends(get_settings),
):
"""Get video thumbnail. No auth required for thumbnails."""
storage = StorageService(settings)
thumbnail_path = storage.get_thumbnail_path(video_id)
if not thumbnail_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Thumbnail not found",
)
return FileResponse(
thumbnail_path,
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=86400"},
)
# Stream Token
@router.post("/videos/{video_id}/token")
async def get_stream_token(
video_id: str,
request: Request,
data: StreamTokenRequest,
_: bool = Depends(verify_api_key),
settings: Settings = Depends(get_settings),
):
"""
Get a JWT token for video streaming.
Token is IP-bound and expires after configured time.
"""
storage = StorageService(settings)
meta = storage.load_metadata(video_id)
if not meta:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Video not found",
)
if meta.get("status") != VideoStatus.READY.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Video not ready. Status: {meta.get('status')}",
)
client_ip = request.client.host if request.client else "unknown"
return create_stream_token(
video_id=video_id,
buchung_id=data.buchung_id,
client_ip=client_ip,
settings=settings,
)
# HLS Streaming
@router.get("/stream/{video_id}/master.m3u8")
async def stream_master_playlist(
video_id: str,
token: str = Query(...),
request: Request = None,
settings: Settings = Depends(get_settings),
):
"""Serve master HLS playlist. Requires valid JWT token."""
# Verify token
verify_stream_token(token, video_id, request, settings)
storage = StorageService(settings)
master_path = storage.get_hls_path(video_id) / "master.m3u8"
if not master_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Playlist not found",
)
# Modify playlist to include token in quality playlist URLs
content = master_path.read_text()
# Add token to each playlist URL
modified_content = re.sub(
r"(\d+p/playlist\.m3u8)",
rf"\1?token={token}",
content,
)
return Response(
content=modified_content,
media_type="application/vnd.apple.mpegurl",
headers={"Cache-Control": "no-cache"},
)
@router.get("/stream/{video_id}/{quality}/playlist.m3u8")
async def stream_quality_playlist(
video_id: str,
quality: str,
token: str = Query(...),
request: Request = None,
settings: Settings = Depends(get_settings),
):
"""Serve quality-specific HLS playlist."""
verify_stream_token(token, video_id, request, settings)
storage = StorageService(settings)
playlist_path = storage.get_hls_path(video_id) / quality / "playlist.m3u8"
if not playlist_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Playlist not found",
)
# Modify segment URLs to include token
content = playlist_path.read_text()
modified_content = re.sub(
r"(segment_\d+\.ts)",
rf"\1?token={token}",
content,
)
return Response(
content=modified_content,
media_type="application/vnd.apple.mpegurl",
headers={"Cache-Control": "no-cache"},
)
@router.api_route("/stream/{video_id}/{quality}/{segment}", methods=["GET", "HEAD"])
async def stream_segment(
video_id: str,
quality: str,
segment: str,
token: str = Query(...),
request: Request = None,
settings: Settings = Depends(get_settings),
):
"""Serve HLS video segment. Supports HEAD for content-length checks."""
verify_stream_token(token, video_id, request, settings)
# Validate segment filename (prevent path traversal)
if not re.match(r"^segment_\d{3}\.ts$", segment):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid segment name",
)
storage = StorageService(settings)
segment_path = storage.get_hls_path(video_id) / quality / segment
if not segment_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Segment not found",
)
return FileResponse(
segment_path,
media_type="video/mp2t",
headers={
"Cache-Control": "public, max-age=31536000", # 1 year - segments don't change
},
)