Enhance media API transcoding and video streaming capabilities

- Added support for limiting concurrent transcoding operations in the media API to prevent resource exhaustion.
- Implemented functions to retrieve video duration and bitrate using ffprobe for improved streaming performance.
- Enhanced the generate_transcoded_stream function to handle HTTP range requests, allowing for more efficient video playback.
- Updated VideoModal component to disable fluid and responsive modes, ensuring proper container boundaries during video playback.
- Improved logging throughout the transcoding process for better error tracking and debugging.
This commit is contained in:
salirezav
2025-11-04 11:55:27 -05:00
parent de46753f15
commit 5070d9b2ca
15 changed files with 391 additions and 99 deletions

View File

@@ -1,8 +1,10 @@
import os
import pathlib
import subprocess
import threading
import time
import urllib.parse
from typing import List, Optional
from typing import List, Optional, Tuple
from fastapi import FastAPI, HTTPException, Response, Request
from fastapi.middleware.cors import CORSMiddleware
@@ -11,6 +13,11 @@ 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)
@@ -238,10 +245,45 @@ def stream_options():
)
def generate_transcoded_stream(file_path: pathlib.Path, start_time: float = 0.0):
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")
@@ -249,56 +291,176 @@ def generate_transcoded_stream(file_path: pathlib.Path, start_time: float = 0.0)
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))
# Acquire semaphore to limit concurrent transcoding
# This prevents resource exhaustion from too many simultaneous FFmpeg processes
semaphore_acquired = False
try:
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=8192
)
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"]
# 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
# If seeking to specific time, use input seeking (before -i, more accurate)
if start_time > 0:
cmd.extend(["-ss", str(start_time)])
# 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]}")
# Input file
cmd.extend(["-i", str(file_path)])
except HTTPException:
raise
except Exception as e:
print(f"FFmpeg transcoding error: {e}")
raise HTTPException(status_code=500, detail=f"Transcoding error: {str(e)}")
# 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")
@@ -309,10 +471,14 @@ 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,
@@ -321,14 +487,69 @@ def stream_transcoded(request: Request, file_id: str, start_time: float = 0.0):
"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, just return headers (we can't know size without transcoding)
# 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)
# 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
# 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,