""" 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 os import requests from flask import Flask, jsonify, render_template_string, request, send_file app = Flask(__name__) API_BASE = os.environ.get("API_BASE", "http://api:3000/api/v1") 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() # --------------------------------------------------------------------------- # 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']}" # 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/input; 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 # Try multiple base paths for video files (NFS mounts may vary) nfs_input = os.environ.get("NAS_INPUT_PATH", "/data/input") nfs_output = os.environ.get("NAS_OUTPUT_PATH", "/mnt/nas/output") candidates = [] if os.path.isabs(file_path): candidates.append(file_path) candidates.append(os.path.join(nfs_input, file_path.lstrip('/'))) candidates.append(os.path.join(nfs_output, file_path.lstrip('/'))) else: candidates.append(os.path.join(nfs_input, file_path)) candidates.append(os.path.join(nfs_output, file_path)) for full_path in candidates: try: return send_file(full_path, conditional=True) except FileNotFoundError: continue return "Video file not found on disk", 404 @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: result = api_post(f"task/{task_id}/complete", { "results": {"reviewer_verdict": verdict}, }) return jsonify({"status": "completed", "task_id": task_id, "verdict": verdict}) 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)