Files
bytetrack-counter-dashboard/recounting_dashboard_upload.py
T

318 lines
11 KiB
Python

#!/usr/bin/env python3
"""
Recounting upload dashboard — upload MP4 then launch recount via commander.
Consumes live counter + recount APIs, proxies recount live-video for preview.
"""
import json
import logging
import os
import re
import shlex
import subprocess
import threading
import time
import requests
from datetime import datetime
from pathlib import Path
from flask import Flask, render_template, jsonify, request, Response, send_file
from werkzeug.serving import WSGIRequestHandler
from werkzeug.utils import secure_filename
from dotenv import load_dotenv
load_dotenv()
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)
app = Flask(__name__, template_folder="templates")
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production")
app.config["MAX_CONTENT_LENGTH"] = 512 * 1024 * 1024 # 512 MB
REPO_ROOT = Path(__file__).resolve().parent
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", str(REPO_ROOT / "uploads")))
LIVE_API_URL = os.getenv("LIVE_API_URL", "http://localhost:5000")
RECOUNT_API_URL = os.getenv("RECOUNT_API_URL", "http://localhost:5001")
SITE_NAME = os.getenv("SITE_NAME", "RECOUNT")
TEMPLATE = os.getenv("RECOUNTING_UPLOAD_TEMPLATE", "recounting_upload.html")
DASHBOARD_PORT = int(os.getenv("RECOUNTING_UPLOAD_PORT", "5003"))
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true"
RECOUNT_CMD = os.getenv("RECOUNT_CMD", "bytetrack-counter config.env --source {path}")
_http_session = requests.Session()
_http_session.timeout = 3
_FILENAME_RE = re.compile(r"batch[_-]?(\d+)[_-](\d{8})[_-]\d{6}", re.IGNORECASE)
_recount_proc = None
_recount_lock = threading.Lock()
_current_file = None
def _parse_filename(filename):
m = _FILENAME_RE.search(filename)
if m:
raw = m.group(2)
return int(m.group(1)), f"{raw[:4]}-{raw[4:6]}-{raw[6:8]}"
return None, None
def _api_get(base_url, path, default=None):
try:
resp = _http_session.get(f"{base_url}{path}")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return default
def _stop_recount_locked():
global _recount_proc
if _recount_proc:
try:
_recount_proc.kill()
_recount_proc.wait(timeout=3)
except Exception:
pass
_recount_proc = None
def _folder_date(filepath, base):
rel = Path(filepath).resolve().relative_to(base.resolve())
parts = rel.parts
if len(parts) >= 2:
return parts[0]
return None
@app.route("/")
def index():
return render_template(TEMPLATE, site_name=SITE_NAME, upload_dir=str(UPLOAD_DIR))
@app.route("/api/live-progress")
def api_live_progress():
data = _api_get(LIVE_API_URL, "/api/current-batch")
if data and data.get("success"):
return jsonify(data)
return jsonify({"success": False, "count": 0, "batch_number": None, "error": "Live unreachable"}), 200
@app.route("/api/recount-progress")
def api_recount_progress():
data = _api_get(RECOUNT_API_URL, "/api/current-batch")
if data and data.get("success"):
return jsonify(data)
return jsonify({"success": False, "count": 0, "batch_number": None, "error": "Recount unreachable"}), 200
@app.route("/api/uploads")
def api_uploads():
files = []
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
for f in sorted(Path(UPLOAD_DIR).glob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True):
st = f.stat()
files.append({
"name": f.name,
"path": str(f),
"size": st.st_size,
"mtime": datetime.fromtimestamp(st.st_mtime).isoformat(),
})
return jsonify(files)
@app.route("/api/upload", methods=["POST"])
def api_upload():
if "file" not in request.files:
return jsonify({"success": False, "error": "No file field"}), 400
f = request.files["file"]
if not f.filename:
return jsonify({"success": False, "error": "No file selected"}), 400
if not f.filename.lower().endswith(".mp4"):
return jsonify({"success": False, "error": "Only .mp4 files allowed"}), 400
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
dest_name = secure_filename(f.filename)
dest_path = Path(UPLOAD_DIR) / dest_name
f.save(str(dest_path))
st = dest_path.stat()
return jsonify({
"success": True,
"name": dest_name,
"path": str(dest_path),
"size": st.st_size,
})
@app.route("/api/delete-upload", methods=["POST"])
def api_delete_upload():
data = request.get_json(force=True) or {}
fpath = data.get("path", "")
if not fpath or not os.path.isfile(fpath):
return jsonify({"success": False, "error": "File not found"}), 404
f = Path(fpath)
if f.parent.resolve() != Path(UPLOAD_DIR).resolve():
return jsonify({"success": False, "error": "Not in upload dir"}), 403
f.unlink()
return jsonify({"success": True})
@app.route("/api/start-recount", methods=["POST"])
def start_recount():
global _recount_proc, _current_file
data = request.get_json(force=True) or {}
upload_path = data.get("path", "")
if not upload_path:
return jsonify({"success": False, "error": "Missing 'path'"}), 400
if not os.path.isfile(upload_path):
return jsonify({"success": False, "error": f"File not found: {upload_path}"}), 404
with _recount_lock:
_stop_recount_locked()
_current_file = upload_path
cmd_str = RECOUNT_CMD.replace("{path}", shlex.quote(str(upload_path)))
try:
_recount_proc = subprocess.Popen(cmd_str, shell=True)
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount started: {cmd_str}")
except Exception as e:
_current_file = None
return jsonify({"success": False, "error": f"Failed to start: {e}"}), 500
try:
resp = _http_session.post(f"{RECOUNT_API_URL}/api/reset", timeout=5)
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → {resp.status_code}")
except requests.exceptions.Timeout:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → timeout")
except requests.exceptions.ConnectionError as e:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → unreachable: {e}")
except Exception as e:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → error: {e}")
return jsonify({
"success": True,
"stream_url": "/api/proxy-stream",
"file": Path(upload_path).name,
"path": upload_path,
})
@app.route("/api/stop-recount", methods=["POST"])
def stop_recount():
global _current_file
with _recount_lock:
_stop_recount_locked()
_current_file = None
return jsonify({"success": True})
@app.route("/api/state")
def api_state():
with _recount_lock:
running = _recount_proc is not None and _recount_proc.poll() is None
current = _current_file
result = {
"streaming": running,
}
if current:
result["file"] = current
result["file_name"] = Path(current).name
result["stream_url"] = "/api/proxy-stream"
batch_num, date_str = _parse_filename(Path(current).name)
if batch_num is not None:
result["batch_number"] = batch_num
result["batch_date"] = date_str
return jsonify(result)
@app.route("/api/proxy-stream")
def api_proxy_stream():
try:
resp = _http_session.get(f"{RECOUNT_API_URL}/api/live-video", stream=True, timeout=5)
if resp.status_code != 200:
return Response("stream unavailable", status=502)
def generate():
for chunk in resp.iter_content(chunk_size=8192):
if chunk:
yield chunk
resp.close()
return Response(
generate(),
mimetype=resp.headers.get("Content-Type", "multipart/x-mixed-replace; boundary=frame"),
)
except Exception:
return Response("stream unavailable", status=502)
@app.route("/api/batch-result")
def api_batch_result():
fpath = request.args.get("file", "")
filename = Path(fpath).name
batch_num, date_str = _parse_filename(filename)
if batch_num is None:
return jsonify({"success": False, "error": "Cannot parse batch number from filename"}), 200
if not date_str:
date_str = _folder_date(fpath, Path(UPLOAD_DIR))
if not date_str:
return jsonify({"success": False, "error": "Cannot determine date from filename"}), 200
def _match_in_batches(batches):
for b in batches:
if b.get("batch_number") == batch_num:
return b
return None
data = _api_get(LIVE_API_URL, f"/api/day-detail/{date_str}")
if data:
b = _match_in_batches(data.get("batches", []))
if b:
return jsonify({
"success": True,
"batch_number": batch_num,
"date": date_str,
"count": b["count"],
"start_time": b.get("start_time"),
"end_time": b.get("end_time"),
})
recent = _api_get(LIVE_API_URL, "/api/recent-batches?limit=100")
if recent:
for entry in recent if isinstance(recent, list) else recent.get("batches", []):
if isinstance(entry, dict) and entry.get("batch_number") == batch_num:
return jsonify({
"success": True,
"batch_number": batch_num,
"date": entry.get("date", ""),
"count": entry["count"],
"start_time": entry.get("start_time"),
"end_time": entry.get("end_time"),
})
return jsonify({"success": False, "error": f"Batch #{batch_num} not found"}), 200
@app.route("/api/download-mp4")
def api_download_mp4():
fpath = request.args.get("file", "")
if not fpath or not os.path.isfile(fpath):
return jsonify({"success": False, "error": "File not found"}), 404
return send_file(fpath, as_attachment=True, download_name=Path(fpath).name)
if __name__ == "__main__":
WSGIRequestHandler.protocol_version = "HTTP/1.1"
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
print(f"Recounting upload dashboard at http://{DASHBOARD_HOST}:{DASHBOARD_PORT}")
print(f"Live API: {LIVE_API_URL}")
print(f"Recount API: {RECOUNT_API_URL}")
print(f"Upload dir: {UPLOAD_DIR}")
print(f"Recount command: {RECOUNT_CMD}")
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)