""" VideoDetect Review UI — Flask Application Lightweight web interface for annotating pending REVIEW tasks. Accessible via http://:5000 — no auth, no SSL (per TC-06). The UI queries the generic API (http://api:3000/api/v1/...) for all data 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, 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: """Perform a GET request to the API and return JSON.""" url = f"{API_BASE}/{path}" resp = requests.get(url, params=params, timeout=10) resp.raise_for_status() return resp.json() def api_post(path: str, data: dict = None) -> dict: """Perform a POST request to the API and return JSON.""" url = f"{API_BASE}/{path}" resp = requests.post(url, json=data or {}, timeout=10) resp.raise_for_status() 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 # --------------------------------------------------------------------------- @app.route("/") def index(): """Index view: list of pending REVIEW tasks with pagination.""" page = request.args.get("page", 1, type=int) per_page = int(os.environ.get("PER_PAGE", 20)) try: data = api_get("tasks", { "type": "REVIEW", "status": "PENDING", "page": page, "per_page": per_page, }) except requests.RequestException as exc: return jsonify({"error": f"API unavailable: {exc}"}), 503 tasks = data.get("tasks", []) total = data.get("total", 0) total_pages = max(1, (total + per_page - 1) // per_page) # Enrich each task with its associated video info from the API enriched = [] for t in tasks: vid_id = t.get("video_id") video_path = "unknown" resolution = None try: vdata = api_get(f"video/{vid_id}") video_path = vdata.get("file_path", "unknown") res_w = vdata.get("resolution_w") res_h = vdata.get("resolution_h") if res_w and res_h: resolution = f"{res_w}x{res_h}" except requests.RequestException: pass enriched.append({ "id": t["id"], "video_id": vid_id, "file_path": video_path, "task_type": t.get("task_type"), "status": t.get("status"), "created_at": t.get("assigned_at") or t.get("created_at"), "resolution": resolution, }) return render_template_string( _INDEX_TEMPLATE, tasks=enriched, page=page, per_page=per_page, total=total, total_pages=total_pages, ) @app.route("/review/") def review_task(task_id): """Detail view: show task info + embedded video player.""" try: data = api_get(f"task/{task_id}") if not data or not data.get("id"): return "Task not found", 404 video_id = data["video_id"] vid_data = api_get(f"video/{video_id}") file_path = vid_data.get("file_path", "unknown") except requests.RequestException as exc: return jsonify({"error": f"API unavailable: {exc}"}), 503 video_obj = dict(data) video_obj["file_path"] = file_path 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", {}) try: video_obj["prev_results"] = json.loads(raw_results) if isinstance(raw_results, str) else (raw_results or {}) except (json.JSONDecodeError, TypeError): video_obj["prev_results"] = {} return render_template_string(_REVIEW_TEMPLATE, video=video_obj) # --------------------------------------------------------------------------- # API routes (Flask → NFS file serving + JSON wrappers) # --------------------------------------------------------------------------- @app.route("/video/") def serve_video(video_id): """Stream video from NFS-mounted /data; supports HTTP range requests.""" # Resolve video path via the API first try: vid_data = api_get(f"video/{video_id}") file_path = vid_data.get("file_path", "") except requests.RequestException: return "Not found", 404 if not file_path or file_path == "unknown": return "Video file path unknown", 404 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/", methods=["POST"]) def api_submit_review(task_id): """Submit review verdict for a task via the generic complete endpoint.""" data = request.get_json(silent=True) or {} verdict = data.get("verdict") # "false_positive", "correct", or other string if not verdict: return jsonify({"error": "verdict is required"}), 400 try: api_response = api_post(f"task/{task_id}/complete", { "results": { "review_verdict": verdict, }, }) return jsonify({ "status": "completed", "task_id": task_id, "verdict": verdict, "api_response": api_response, }) except requests.RequestException as exc: return jsonify({"error": f"API error: {exc}"}), 503 @app.route("/api/review/skip/", methods=["POST"]) def api_skip_review(task_id): """Skip (unassign + reset) a task so it returns to the PENDING queue.""" try: result = api_post(f"task/{task_id}/unassign", {"reset": 1}) return jsonify({"status": "skipped", "task_id": task_id}) except requests.RequestException as exc: return jsonify({"error": f"API error: {exc}"}), 503 @app.route("/api/review/stats") def api_stats(): """Return a summary of REVIEW tasks (via two API calls).""" try: pending = api_get("tasks", {"type": "REVIEW", "status": "PENDING"}) or {} completed = api_get("tasks", {"type": "REVIEW", "status": "COMPLETED"}) or {} failed = api_get("tasks", {"type": "REVIEW", "status": "FAILED"}) or {} return jsonify({ "total_in_queue": pending.get("total", 0), "completed": completed.get("total", 0), "failed": failed.get("total", 0), }) except requests.RequestException as exc: return jsonify({"error": f"API error: {exc}"}), 503 # --------------------------------------------------------------------------- # Templates # --------------------------------------------------------------------------- _INDEX_TEMPLATE = """ VideoDetect — Review Queue

VideoDetect

Review Queue — {{ total }} pending — Page {{ page }}/{{ total_pages }}
{{ total }}
In Queue
{{ per_page }}
Per Page
{% if tasks %}
{% for t in tasks %} {% endfor %}
IDFile PathResolutionTask TypeStatusDate
{{ t.id }} {{ t.file_path }} {{ t.resolution or '—' }} {{ t.task_type }} {{ t.status }} {{ t.created_at|string|truncate(19, True, '') if t.created_at else '—' }} Review
{% else %}

No videos pending review

All REVIEW tasks have been completed.

{% endif %}
""" _REVIEW_TEMPLATE = """ VideoDetect — Review #{{ video.id }}

VideoDetect

« Back to queue
{% if video.prev_results and video.prev_results.review_verdict %}
Previously reviewed: {{ video.prev_results.review_verdict }}
{% endif %}

Task information

Task ID
{{ video.id }}
Task Type
{{ video.task_type }}
Status
{{ video.status }}
Resolution
{{ video.resolution or '—' }}
Duration
{{ "%.1f"|format(video.video_duration) if video.video_duration else '—' }}s
Assigned
{{ video.assigned_at|string|truncate(19, True, '') if video.assigned_at else '—' }}
{{ video.file_path }}

Video

Review Actions

""" # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- if __name__ == "__main__": port = int(os.environ.get("PORT", 5000)) debug = os.environ.get("FLASK_DEBUG", "0") == "1" app.run(host="0.0.0.0", port=port, debug=debug)