Enhance video streaming capabilities and UI integration

- Added support for streaming video files with proper MIME type handling in the media API.
- Implemented transcoding functionality for H.264 compatibility on-the-fly using FFmpeg.
- Updated VideoModal component to utilize Video.js for improved video playback experience.
- Enhanced user interface with download options and better error handling for video playback.
- Updated package.json and package-lock.json to include new dependencies for video.js and related types.
This commit is contained in:
salirezav
2025-10-31 18:29:05 -04:00
parent 00d4e5b275
commit 70f614e9ff
4 changed files with 666 additions and 24 deletions

View File

@@ -6,7 +6,7 @@ from typing import List, Optional
from fastapi import FastAPI, HTTPException, Response, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
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()
@@ -123,6 +123,19 @@ def get_thumbnail_q(file_id: str, width: int = 320, height: int = 180):
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:
@@ -134,13 +147,48 @@ def open_file_range(path: pathlib.Path, start: int, end: Optional[int]):
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:
return FileResponse(p, media_type="video/mp4")
# parse bytes=START-END
# 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:
@@ -148,21 +196,128 @@ def stream_file(request: Request, file_id: str):
end = int(end_str) if end_str else None
except ValueError:
raise HTTPException(status_code=400, detail="Bad Range header")
chunk, size, start, end = open_file_range(p, start, end)
# 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 = {
"Content-Range": f"bytes {start}-{end}/{size}",
"Accept-Ranges": "bytes",
**base_headers,
"Content-Range": f"bytes {actual_start}-{actual_end}/{size}",
"Content-Length": str(len(chunk)),
"Content-Type": "video/mp4",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Access-Control-Allow-Origin": "*",
}
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.
"""
# 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
while True:
chunk = process.stdout.read(chunk_size)
if not chunk:
break
yield chunk
process.wait()
except Exception as e:
print(f"FFmpeg transcoding error: {e}")
raise
@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
)