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:
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user