From 75f38ec7ed9d4e7e9979673273e74a4da67f69f0 Mon Sep 17 00:00:00 2001 From: Ryan Shpeherd Date: Tue, 15 Sep 2026 13:28:43 -0400 Subject: [PATCH] Fixes --- ui/app.py | 173 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 156 insertions(+), 17 deletions(-) diff --git a/ui/app.py b/ui/app.py index afbb3ec..e723354 100644 --- a/ui/app.py +++ b/ui/app.py @@ -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/", methods=["POST"]) @@ -340,7 +479,7 @@ _REVIEW_TEMPLATE = """

Video