import os import pathlib import subprocess import urllib.parse from typing import List, Optional from fastapi import FastAPI, HTTPException, Response, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse MEDIA_DIR = pathlib.Path(os.getenv("MEDIA_VIDEOS_DIR", "/mnt/videos")).resolve() THUMBS_DIR = pathlib.Path(os.getenv("MEDIA_THUMBS_DIR", MEDIA_DIR / ".thumbnails")).resolve() app = FastAPI(title="Media API", version="0.1.0") # CORS for dashboard at exp-dash:8080 (and localhost for convenience) app.add_middleware( CORSMiddleware, allow_origins=["http://exp-dash:8080", "http://localhost:8080"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"] ) def list_video_files() -> List[pathlib.Path]: exts = {".mp4", ".avi", ".mov", ".mkv"} files: List[pathlib.Path] = [] for root, _, fnames in os.walk(MEDIA_DIR): # skip thumbnails dir if THUMBS_DIR.as_posix() in pathlib.Path(root).as_posix(): continue for f in fnames: p = pathlib.Path(root) / f if p.suffix.lower() in exts: files.append(p) return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True) def file_id_from_path(p: pathlib.Path) -> str: return urllib.parse.quote_plus(p.relative_to(MEDIA_DIR).as_posix()) def path_from_file_id(fid: str) -> pathlib.Path: rel = urllib.parse.unquote_plus(fid) p = (MEDIA_DIR / rel).resolve() if not p.is_file() or MEDIA_DIR not in p.parents: raise HTTPException(status_code=404, detail="Video not found") return p def ensure_thumbnail(p: pathlib.Path, width: int = 320, height: int = 180) -> pathlib.Path: THUMBS_DIR.mkdir(parents=True, exist_ok=True) thumb_name = file_id_from_path(p) + f"_{width}x{height}.jpg" out = THUMBS_DIR / thumb_name if out.exists(): return out # generate from 1s offset or 0 if too short cmd = [ "ffmpeg", "-y", "-ss", "1", "-i", str(p), "-vframes", "1", "-vf", f"scale='min({width},iw)':-2,scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2", str(out) ] try: subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except subprocess.CalledProcessError: raise HTTPException(status_code=500, detail="Failed to generate thumbnail") return out @app.get("/health") def health() -> dict: return {"status": "ok"} @app.get("/videos/") def list_videos(camera_name: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, limit: int = 24, page: int = 1) -> dict: files = list_video_files() # basic filtering placeholder (camera_name can be inferred from path segments) if camera_name: files = [p for p in files if camera_name in p.as_posix()] total = len(files) start = max(0, (page - 1) * limit) slice_ = files[start:start + limit] items = [] for p in slice_: stat = p.stat() items.append({ "file_id": file_id_from_path(p), "camera_name": p.parent.name, "filename": p.name, "file_size_bytes": stat.st_size, "format": p.suffix.lstrip('.'), "status": "completed", "created_at": int(stat.st_mtime), "is_streamable": True, "needs_conversion": False, }) total_pages = (total + limit - 1) // limit if limit else 1 return { "videos": items, "total_count": total, "page": page, "total_pages": total_pages, } @app.get("/videos/{file_id:path}/thumbnail") def get_thumbnail(file_id: str, width: int = 320, height: int = 180): p = path_from_file_id(file_id) thumb = ensure_thumbnail(p, width, height) return FileResponse(thumb, media_type="image/jpeg") # Convenience endpoint: pass file_id via query instead of path (accepts raw or URL-encoded) @app.get("/videos/thumbnail") def get_thumbnail_q(file_id: str, width: int = 320, height: int = 180): p = path_from_file_id(file_id) thumb = ensure_thumbnail(p, width, height) return FileResponse(thumb, media_type="image/jpeg") def get_video_mime_type(path: pathlib.Path) -> str: """Get MIME type based on file extension""" ext = path.suffix.lower() mime_types = { ".mp4": "video/mp4", ".avi": "video/x-msvideo", ".mov": "video/quicktime", ".mkv": "video/x-matroska", ".webm": "video/webm", } return mime_types.get(ext, "video/mp4") def open_file_range(path: pathlib.Path, start: int, end: Optional[int]): file_size = path.stat().st_size if end is None or end >= file_size: end = file_size - 1 length = end - start + 1 with open(path, 'rb') as f: f.seek(start) chunk = f.read(length) return chunk, file_size, start, end @app.head("/videos/{file_id:path}/stream") @app.get("/videos/{file_id:path}/stream") def stream_file(request: Request, file_id: str): p = path_from_file_id(file_id) file_size = p.stat().st_size content_type = get_video_mime_type(p) range_header = request.headers.get("range") # Base headers for all responses base_headers = { "Accept-Ranges": "bytes", "Content-Type": content_type, "Cache-Control": "public, max-age=3600", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", "Access-Control-Allow-Headers": "Range, Content-Type", "Access-Control-Expose-Headers": "Content-Range, Accept-Ranges, Content-Length", } # For HEAD requests, just return headers without body if request.method == "HEAD": headers = { **base_headers, "Content-Length": str(file_size), } return Response(status_code=200, headers=headers) if not range_header: # No range request - return full file with proper headers headers = { **base_headers, "Content-Length": str(file_size), } return FileResponse( p, media_type=content_type, headers=headers, status_code=200 ) # Parse range request: bytes=START-END range_value = range_header.strip().lower().replace("bytes=", "") start_str, _, end_str = range_value.partition("-") try: start = int(start_str) if start_str else 0 end = int(end_str) if end_str else None except ValueError: raise HTTPException(status_code=400, detail="Bad Range header") # Validate range if start < 0: start = 0 if end is None or end >= file_size: end = file_size - 1 if start > end: raise HTTPException(status_code=416, detail="Range Not Satisfiable") chunk, size, actual_start, actual_end = open_file_range(p, start, end) headers = { **base_headers, "Content-Range": f"bytes {actual_start}-{actual_end}/{size}", "Content-Length": str(len(chunk)), } return Response(content=chunk, status_code=206, headers=headers) # Convenience endpoint: pass file_id via query instead of path (accepts raw or URL-encoded) @app.head("/videos/stream") @app.get("/videos/stream") def stream_file_q(request: Request, file_id: str): return stream_file(request, file_id) # Handle OPTIONS requests for CORS preflight @app.options("/videos/{file_id:path}/stream") @app.options("/videos/stream") @app.options("/videos/{file_id:path}/stream-transcoded") @app.options("/videos/stream-transcoded") def stream_options(): return Response( status_code=200, headers={ "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", "Access-Control-Allow-Headers": "Range, Content-Type", "Access-Control-Max-Age": "86400", } ) def generate_transcoded_stream(file_path: pathlib.Path, start_time: float = 0.0): """ Transcode video to H.264 on-the-fly using FFmpeg. Streams H.264/MP4 that browsers can actually play. """ if not file_path.exists(): raise HTTPException(status_code=404, detail="Video file not found") if file_path.stat().st_size == 0: raise HTTPException(status_code=500, detail="Video file is empty (0 bytes)") # FFmpeg command to transcode to H.264 with web-optimized settings cmd = [ "ffmpeg", "-i", str(file_path), "-c:v", "libx264", # H.264 codec "-preset", "ultrafast", # Fast encoding for real-time "-tune", "zerolatency", # Low latency "-crf", "23", # Quality (18-28, lower = better) "-c:a", "aac", # AAC audio if present "-movflags", "+faststart", # Web-optimized (moov atom at beginning) "-f", "mp4", # MP4 container "-" # Output to stdout ] # If seeking to specific time if start_time > 0: cmd.insert(-2, "-ss") cmd.insert(-2, str(start_time)) try: process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=8192 ) # Stream chunks chunk_size = 8192 bytes_yielded = 0 while True: chunk = process.stdout.read(chunk_size) if not chunk: break bytes_yielded += len(chunk) yield chunk # Check for errors process.wait() if process.returncode != 0: stderr = process.stderr.read().decode('utf-8', errors='ignore') print(f"FFmpeg error (code {process.returncode}): {stderr}") if bytes_yielded == 0: raise HTTPException(status_code=500, detail=f"FFmpeg transcoding failed: {stderr[:200]}") except HTTPException: raise except Exception as e: print(f"FFmpeg transcoding error: {e}") raise HTTPException(status_code=500, detail=f"Transcoding error: {str(e)}") @app.head("/videos/{file_id:path}/stream-transcoded") @app.get("/videos/{file_id:path}/stream-transcoded") @app.head("/videos/stream-transcoded") @app.get("/videos/stream-transcoded") def stream_transcoded(request: Request, file_id: str, start_time: float = 0.0): """ Stream video transcoded to H.264 on-the-fly. This endpoint converts MPEG-4 Part 2 videos to H.264 for browser compatibility. """ p = path_from_file_id(file_id) content_type = "video/mp4" # Base headers headers = { "Content-Type": content_type, "Cache-Control": "no-cache, no-store, must-revalidate", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", "Access-Control-Allow-Headers": "Range, Content-Type", "Access-Control-Expose-Headers": "Content-Range, Accept-Ranges, Content-Length", } # For HEAD requests, just return headers (we can't know size without transcoding) if request.method == "HEAD": return Response(status_code=200, headers=headers) # Note: Range requests are complex with transcoding, so we'll transcode from start # For better performance with range requests, we'd need to cache transcoded segments return StreamingResponse( generate_transcoded_stream(p, start_time), media_type=content_type, headers=headers )