Fixes
This commit is contained in:
@@ -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,24 +260,45 @@ 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))
|
||||
|
||||
for full_path in candidates:
|
||||
try:
|
||||
return send_file(full_path, conditional=True)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
full_path = resolve_video_path(file_path)
|
||||
if not full_path:
|
||||
return "Video file not found on disk", 404
|
||||
|
||||
mime_type = guess_video_mimetype(full_path)
|
||||
file_size = os.path.getsize(full_path)
|
||||
range_header = request.headers.get("Range")
|
||||
|
||||
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"])
|
||||
def api_submit_review(task_id):
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user