This commit is contained in:
Ryan Shpeherd
2026-09-15 13:28:43 -04:00
parent 5fa67a733c
commit 75f38ec7ed
+156 -17
View File
@@ -9,14 +9,34 @@ and posts review results back through the same API. No direct DB access.
"""
import json
import mimetypes
import os
from urllib.parse import quote
import requests
from flask import Flask, jsonify, render_template_string, request, send_file
from flask import Flask, Response, jsonify, render_template_string, request, send_file
app = Flask(__name__)
API_BASE = os.environ.get("API_BASE", "http://api:3000/api/v1")
VIDEO_CHUNK_SIZE = 1024 * 1024
VIDEO_MIME_TYPES = {
".3gp": "video/3gpp",
".avi": "video/x-msvideo",
".flv": "video/x-flv",
".m2ts": "video/mp2t",
".m4v": "video/mp4",
".mkv": "video/x-matroska",
".mov": "video/quicktime",
".mp4": "video/mp4",
".mpeg": "video/mpeg",
".mpg": "video/mpeg",
".ogv": "video/ogg",
".ts": "video/mp2t",
".webm": "video/webm",
".wmv": "video/x-ms-wmv",
}
def api_get(path: str, params: dict = None) -> dict:
@@ -35,6 +55,102 @@ def api_post(path: str, data: dict = None) -> dict:
return resp.json()
def candidate_video_paths(file_path: str) -> list[str]:
"""Return possible local paths for a video path stored by the API."""
if os.path.isabs(file_path):
return [file_path]
nfs_base = os.environ.get("NAS_INPUT_PATH", "/data")
return [os.path.join(nfs_base, file_path)]
def resolve_video_path(file_path: str) -> str | None:
"""Find the first readable local path for a video."""
for full_path in candidate_video_paths(file_path):
if os.path.isfile(full_path):
return full_path
return None
def guess_video_mimetype(file_path: str) -> str:
"""Infer a browser-facing media type from the filename, then file signature."""
ext = os.path.splitext(file_path)[1].lower()
if ext in VIDEO_MIME_TYPES:
return VIDEO_MIME_TYPES[ext]
guessed, _ = mimetypes.guess_type(file_path)
if guessed:
return guessed
try:
with open(file_path, "rb") as fh:
header = fh.read(512)
except OSError:
return "application/octet-stream"
if len(header) >= 12 and header[4:8] == b"ftyp":
return "video/quicktime" if header[8:12] == b"qt " else "video/mp4"
if header.startswith(b"\x1a\x45\xdf\xa3"):
return "video/webm" if b"webm" in header.lower() else "video/x-matroska"
if header.startswith(b"RIFF") and header[8:12] == b"AVI ":
return "video/x-msvideo"
if header.startswith(b"FLV"):
return "video/x-flv"
if header.startswith(b"\x00\x00\x01\xba") or header.startswith(b"\x00\x00\x01"):
return "video/mpeg"
return "application/octet-stream"
def parse_range_header(range_header: str, file_size: int) -> tuple[int, int] | None:
"""Parse a single HTTP byte range, returning inclusive start/end offsets."""
if not range_header.lower().startswith("bytes="):
return None
ranges = range_header.partition("=")[2].split(",")
if len(ranges) != 1 or "-" not in ranges[0]:
return None
start_s, end_s = ranges[0].strip().split("-", 1)
try:
if start_s:
start = int(start_s)
end = int(end_s) if end_s else file_size - 1
else:
suffix_length = int(end_s)
if suffix_length <= 0:
return None
start = max(file_size - suffix_length, 0)
end = file_size - 1
except ValueError:
return None
if start < 0 or end < start or start >= file_size:
return None
return start, min(end, file_size - 1)
def iter_file_range(file_path: str, start: int, end: int):
"""Yield a byte range from disk without loading the full video into memory."""
with open(file_path, "rb") as fh:
fh.seek(start)
remaining = end - start + 1
while remaining > 0:
chunk = fh.read(min(VIDEO_CHUNK_SIZE, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
def content_disposition(filename: str) -> str:
"""Build an inline Content-Disposition value that tolerates spaces/unicode."""
ascii_name = filename.encode("ascii", "ignore").decode("ascii").replace('"', "")
ascii_name = ascii_name or "video"
return f"inline; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
# ---------------------------------------------------------------------------
# HTML routes
# ---------------------------------------------------------------------------
@@ -114,6 +230,8 @@ def review_task(task_id):
video_obj["video_duration"] = vid_data.get("duration")
video_obj["resolution"] = vid_data.get("resolution_w") and vid_data.get("resolution_h") \
and f"{vid_data['resolution_w']}x{vid_data['resolution_h']}"
resolved_path = resolve_video_path(file_path)
video_obj["mime_type"] = guess_video_mimetype(resolved_path or file_path)
# Parse results if already present
raw_results = data.get("results", {})
@@ -142,23 +260,44 @@ def serve_video(video_id):
if not file_path or file_path == "unknown":
return "Video file path unknown", 404
# The NFS share is mounted at /data inside the container.
# DB stores absolute paths starting with /data/... which should work directly.
# Also try env-var overrides in case mounts differ from the default.
candidates = []
if os.path.isabs(file_path):
candidates.append(file_path)
else:
nfs_base = os.environ.get("NAS_INPUT_PATH", "/data")
candidates.append(os.path.join(nfs_base, file_path))
full_path = resolve_video_path(file_path)
if not full_path:
return "Video file not found on disk", 404
for full_path in candidates:
try:
return send_file(full_path, conditional=True)
except FileNotFoundError:
continue
mime_type = guess_video_mimetype(full_path)
file_size = os.path.getsize(full_path)
range_header = request.headers.get("Range")
return "Video file not found on disk", 404
if range_header:
byte_range = parse_range_header(range_header, file_size)
if not byte_range:
return Response(
status=416,
headers={"Content-Range": f"bytes */{file_size}"},
)
start, end = byte_range
response = Response(
iter_file_range(full_path, start, end),
status=206,
mimetype=mime_type,
direct_passthrough=True,
)
response.headers["Accept-Ranges"] = "bytes"
response.headers["Content-Length"] = str(end - start + 1)
response.headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
response.headers["Content-Disposition"] = content_disposition(os.path.basename(full_path))
return response
response = send_file(
full_path,
mimetype=mime_type,
as_attachment=False,
download_name=os.path.basename(full_path),
conditional=True,
)
response.headers["Accept-Ranges"] = "bytes"
return response
@app.route("/api/review/submit/<int:task_id>", methods=["POST"])
@@ -340,7 +479,7 @@ _REVIEW_TEMPLATE = """
<h2>Video</h2>
<div class="video-wrap">
<video controls preload="metadata" id="review-video">
<source src="/video/{{ video.video_id }}">
<source src="/video/{{ video.video_id }}"{% if video.mime_type.startswith('video/') %} type="{{ video.mime_type }}"{% endif %}>
Video playback not supported.
</video>
</div>