""" 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 }, )