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 json
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import requests
|
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__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
API_BASE = os.environ.get("API_BASE", "http://api:3000/api/v1")
|
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:
|
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()
|
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
|
# HTML routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -114,6 +230,8 @@ def review_task(task_id):
|
|||||||
video_obj["video_duration"] = vid_data.get("duration")
|
video_obj["video_duration"] = vid_data.get("duration")
|
||||||
video_obj["resolution"] = vid_data.get("resolution_w") and vid_data.get("resolution_h") \
|
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']}"
|
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
|
# Parse results if already present
|
||||||
raw_results = data.get("results", {})
|
raw_results = data.get("results", {})
|
||||||
@@ -142,23 +260,44 @@ def serve_video(video_id):
|
|||||||
if not file_path or file_path == "unknown":
|
if not file_path or file_path == "unknown":
|
||||||
return "Video file path unknown", 404
|
return "Video file path unknown", 404
|
||||||
|
|
||||||
# The NFS share is mounted at /data inside the container.
|
full_path = resolve_video_path(file_path)
|
||||||
# DB stores absolute paths starting with /data/... which should work directly.
|
if not full_path:
|
||||||
# Also try env-var overrides in case mounts differ from the default.
|
return "Video file not found on disk", 404
|
||||||
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:
|
mime_type = guess_video_mimetype(full_path)
|
||||||
try:
|
file_size = os.path.getsize(full_path)
|
||||||
return send_file(full_path, conditional=True)
|
range_header = request.headers.get("Range")
|
||||||
except FileNotFoundError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
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"])
|
@app.route("/api/review/submit/<int:task_id>", methods=["POST"])
|
||||||
@@ -340,7 +479,7 @@ _REVIEW_TEMPLATE = """
|
|||||||
<h2>Video</h2>
|
<h2>Video</h2>
|
||||||
<div class="video-wrap">
|
<div class="video-wrap">
|
||||||
<video controls preload="metadata" id="review-video">
|
<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 playback not supported.
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user