import os import pathlib import subprocess import threading import time import urllib.parse from typing import List, Optional, Tuple 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() # Limit concurrent transcoding operations to prevent resource exhaustion # Adjust based on your CPU cores and available memory MAX_CONCURRENT_TRANSCODING = int(os.getenv("MAX_CONCURRENT_TRANSCODING", "2")) transcoding_semaphore = threading.Semaphore(MAX_CONCURRENT_TRANSCODING) 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 get_video_info(file_path: pathlib.Path) -> Tuple[float, Optional[int]]: """Get video duration and bitrate using ffprobe""" try: # Get duration cmd_duration = [ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(file_path) ] result_duration = subprocess.run(cmd_duration, capture_output=True, text=True, check=True) duration = float(result_duration.stdout.strip()) # Get bitrate cmd_bitrate = [ "ffprobe", "-v", "error", "-show_entries", "format=bit_rate", "-of", "default=noprint_wrappers=1:nokey=1", str(file_path) ] result_bitrate = subprocess.run(cmd_bitrate, capture_output=True, text=True, check=True) bitrate_str = result_bitrate.stdout.strip() bitrate = int(bitrate_str) if bitrate_str and bitrate_str.isdigit() else None return duration, bitrate except (subprocess.CalledProcessError, ValueError): # Fallback: estimate from file size (very rough estimate) file_size_mb = file_path.stat().st_size / (1024 * 1024) duration = max(10.0, file_size_mb * 20) # Rough estimate: 20 seconds per MB return duration, None def generate_transcoded_stream(file_path: pathlib.Path, start_time: float = 0.0, duration: Optional[float] = None): """ Transcode video to H.264 on-the-fly using FFmpeg. Streams H.264/MP4 that browsers can actually play. Uses semaphore to limit concurrent transcoding operations. """ 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)") # Acquire semaphore to limit concurrent transcoding # This prevents resource exhaustion from too many simultaneous FFmpeg processes semaphore_acquired = False try: if not transcoding_semaphore.acquire(blocking=False): raise HTTPException( status_code=503, detail=f"Server busy: Maximum concurrent transcoding operations ({MAX_CONCURRENT_TRANSCODING}) reached. Please try again in a moment." ) semaphore_acquired = True # FFmpeg command to transcode to H.264 with web-optimized settings # Use fragmented MP4 for HTTP streaming (doesn't require seekable output) # frag_keyframe: fragment at keyframes # dash: use DASH-compatible fragmentation # omit_tfhd_offset: avoid seeking by omitting track fragment header offset # Optimized for resource usage: ultrafast preset, limited threads # Build command with proper order: input seeking first, then input, then filters/codecs cmd = ["ffmpeg"] # If seeking to specific time, use input seeking (before -i, more accurate) if start_time > 0: cmd.extend(["-ss", str(start_time)]) # Input file cmd.extend(["-i", str(file_path)]) # Video codec settings cmd.extend([ "-c:v", "libx264", # H.264 codec "-preset", "ultrafast", # Fast encoding for real-time (lowest CPU usage) "-tune", "zerolatency", # Low latency "-crf", "23", # Quality (18-28, lower = better) "-threads", "2", # Limit threads to reduce CPU usage (adjust based on CPU cores) "-max_muxing_queue_size", "1024", # Prevent buffer overflow ]) # If duration is specified (for range requests), limit output duration if duration is not None: cmd.extend(["-t", str(duration)]) # Audio codec settings cmd.extend([ "-c:a", "aac", # AAC audio if present "-b:a", "128k", # Limit audio bitrate to save resources ]) # Output format settings cmd.extend([ "-movflags", "frag_keyframe+dash+omit_tfhd_offset", # Fragmented MP4 optimized for HTTP streaming "-f", "mp4", # MP4 container "-" # Output to stdout ]) process = None stderr_thread = None try: process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0 # Unbuffered for better streaming ) # Stream chunks chunk_size = 8192 bytes_yielded = 0 stderr_data = [] # Read stderr in background (to avoid blocking) def read_stderr(): while True: chunk = process.stderr.read(1024) if not chunk: break stderr_data.append(chunk) stderr_thread = threading.Thread(target=read_stderr, daemon=True) stderr_thread.start() try: while True: chunk = process.stdout.read(chunk_size) if not chunk: break bytes_yielded += len(chunk) yield chunk except GeneratorExit: # Generator was closed/stopped - cleanup process if process and process.poll() is None: process.terminate() # Wait briefly, then force kill if needed time.sleep(0.5) if process.poll() is None: try: process.kill() except Exception: pass raise except Exception as e: # Error during streaming - cleanup and re-raise if process and process.poll() is None: process.terminate() # Wait briefly, then force kill if needed time.sleep(0.5) if process.poll() is None: try: process.kill() except Exception: pass raise # Wait for process to finish and stderr thread to complete process.wait() if stderr_thread: stderr_thread.join(timeout=1) # Check for errors if process.returncode != 0: stderr = b''.join(stderr_data).decode('utf-8', errors='ignore') # Extract actual error message (skip version banner) error_lines = stderr.split('\n') # Skip version/configuration lines and get actual error error_msg = '\n'.join([line for line in error_lines if line and not line.startswith('ffmpeg version') and not line.startswith('built with') and not line.startswith('configuration:') and not line.startswith('libav') and 'Copyright' not in line]) # If no meaningful error found, use last few lines if not error_msg.strip(): error_msg = '\n'.join(error_lines[-10:]) print(f"FFmpeg error (code {process.returncode}): Full stderr:\n{stderr}") print(f"FFmpeg command was: {' '.join(cmd)}") if bytes_yielded == 0: # Show first 500 chars of actual error (not just version info) error_detail = error_msg[:500] if error_msg else stderr[:500] raise HTTPException(status_code=500, detail=f"FFmpeg transcoding failed: {error_detail}") except HTTPException: raise except Exception as e: # Ensure process is cleaned up on any error if process and process.poll() is None: try: process.terminate() # Wait briefly, then force kill if needed time.sleep(0.5) if process.poll() is None: try: process.kill() except Exception: pass except Exception: try: process.kill() except Exception: pass print(f"FFmpeg transcoding error: {e}") raise HTTPException(status_code=500, detail=f"Transcoding error: {str(e)}") finally: # Always release semaphore when done (success or error) # Only release if we actually acquired it if semaphore_acquired: try: transcoding_semaphore.release() except Exception as e: print(f"Error releasing semaphore: {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. Supports seeking via HTTP Range requests or start_time parameter. """ p = path_from_file_id(file_id) content_type = "video/mp4" # Get video duration and bitrate for range request handling video_duration, original_bitrate = get_video_info(p) # 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", "Accept-Ranges": "bytes", } # For HEAD requests, return headers (estimate size) if request.method == "HEAD": # Rough estimate: ~2-3 MB per minute of video estimated_size = int(video_duration * 50000) # ~50KB per second estimate headers["Content-Length"] = str(estimated_size) return Response(status_code=200, headers=headers) # Handle Range requests for seeking range_header = request.headers.get("range") if range_header: # Parse range request: bytes=START-END range_value = range_header.strip().lower().replace("bytes=", "") start_str, _, end_str = range_value.partition("-") try: byte_start = int(start_str) if start_str else 0 byte_end = int(end_str) if end_str else None except ValueError: # Invalid range, ignore and stream from start range_header = None if range_header: # For seeking, convert byte range to time-based seeking # Estimate transcoded bitrate (H.264 is typically more efficient than original) # Use original bitrate if available, otherwise estimate if original_bitrate: # H.264 transcoding typically uses 70-80% of original bitrate at same quality transcoded_bitrate = int(original_bitrate * 0.75) else: # Default estimate: 2 Mbps transcoded_bitrate = 2000000 estimated_total_bytes = int(video_duration * transcoded_bitrate / 8) if estimated_total_bytes > 0 and byte_start < estimated_total_bytes: # Calculate time position from byte offset time_start_sec = (byte_start / estimated_total_bytes) * video_duration time_start_sec = max(0.0, min(time_start_sec, video_duration - 0.5)) # For seeking, don't limit duration - stream to end # The browser will handle buffering duration_sec = None # None means stream to end # Update headers for range response # For seeking, we typically don't know the exact end, so estimate actual_byte_end = min(byte_end or estimated_total_bytes - 1, estimated_total_bytes - 1) headers["Content-Range"] = f"bytes {byte_start}-{actual_byte_end}/{estimated_total_bytes}" headers["Content-Length"] = str(actual_byte_end - byte_start + 1) # Stream from the calculated time position using FFmpeg's -ss flag # Duration is None, so it will stream to the end return StreamingResponse( generate_transcoded_stream(p, time_start_sec, duration_sec), media_type=content_type, headers=headers, status_code=206 # Partial Content ) # No range request or invalid range - stream from start_time return StreamingResponse( generate_transcoded_stream(p, start_time), media_type=content_type, headers=headers )