Add recounting upload dashboard with MP4 upload before recount

This commit is contained in:
proitlab committed 2026-08-05 00:26:31 +07:00
1 parent f6a388cdb9
commit bcceaae626
3 files changed
+1304

No files matched your search

+11
View File
@@ -177,3 +177,14 @@ GO2RTC_URL=http://localhost:1984
RTSP_PORT=8554 RTSP_PORT=8554
# Stream name pushed to RTSP and consumed from go2rtc # Stream name pushed to RTSP and consumed from go2rtc
RECOUNT_STREAM_NAME=stream_1 RECOUNT_STREAM_NAME=stream_1
# --- Recounting Upload Dashboard (recounting_dashboard_upload.py) ---
# Bind port for the upload-first recount dashboard (distinct from 5002)
RECOUNTING_UPLOAD_PORT=5003
# Directory where uploaded MP4 files are stored
UPLOAD_DIR=/opt/bytetrack-counter/uploads
# Command to launch the recounting counter process (placeholder, fill in real command).
# {path} is substituted with the absolute path of the uploaded MP4 file.
RECOUNT_CMD=bytetrack-counter config.env --source {path}
# Template file for the upload dashboard: "recounting_upload.html" (default)
RECOUNTING_UPLOAD_TEMPLATE=recounting_upload.html
+316
View File
@@ -0,0 +1,316 @@
#!/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+)", re.IGNORECASE)
_recount_proc = None
_recount_lock = threading.Lock()
_current_file = None
def _parse_filename(filename):
m = _FILENAME_RE.search(filename)
if m:
return int(m.group(1)), None
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)
safe = secure_filename(f.filename)
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
dest_name = f"{ts}_{safe}"
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, _ = _parse_filename(filename)
if batch_num is None:
return jsonify({"success": False, "error": "Cannot parse batch number from filename"}), 200
def _match_in_batches(batches):
for b in batches:
if b.get("batch_number") == batch_num:
return b
return None
date_str = _folder_date(fpath, Path(UPLOAD_DIR))
if date_str:
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)
+977
View File
@@ -0,0 +1,977 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site_name }} Recounting Upload</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Orbitron:wght@400;500;700;900&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-deep: #06080e;
--bg-surface: rgba(12, 16, 28, 0.9);
--border-glow: rgba(0, 240, 255, 0.12);
--accent: #00f0ff;
--accent2: #7b2fff;
--accent3: #ff2d78;
--accent4: #00ff88;
--text-primary: #dce2f0;
--text-secondary: #6b7394;
--glass: rgba(12, 16, 32, 0.7);
--cell-bg: rgba(255, 255, 255, 0.02);
--cell-border: rgba(255, 255, 255, 0.05);
--card-bg: rgba(255, 255, 255, 0.025);
--divider: rgba(255, 255, 255, 0.05);
--input-bg: rgba(0, 0, 0, 0.4);
--radius: 18px;
}
body {
font-family: 'JetBrains Mono', monospace;
background: var(--bg-deep);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
.bg-grid {
position: fixed; inset: 0; z-index: 0;
background-image:
linear-gradient(rgba(0, 240, 255, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 240, 255, 0.025) 1px, transparent 1px);
background-size: 64px 64px;
animation: gridScroll 20s linear infinite;
pointer-events: none;
}
@keyframes gridScroll {
0% { background-position: 0 0; }
100% { background-position: 64px 64px; }
}
.orb {
position: fixed; border-radius: 50%; filter: blur(140px); pointer-events: none;
animation: orbFloat 18s ease-in-out infinite;
}
.orb-1 { width: 600px; height: 600px; background: rgba(0, 240, 255, 0.04); top: -250px; left: -150px; }
.orb-2 { width: 500px; height: 500px; background: rgba(123, 47, 255, 0.04); bottom: -200px; right: -100px; animation-delay: -6s; }
@keyframes orbFloat {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(50px, -40px) scale(1.06); }
66% { transform: translate(-30px, 30px) scale(0.94); }
}
.container {
position: relative; z-index: 1;
max-width: 1600px; margin: 0 auto; padding: 24px 20px;
}
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 28px; margin-bottom: 24px;
background: var(--glass);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glow); border-radius: var(--radius);
}
.header-left { display: flex; align-items: center; gap: 14px; }
.logo-icon {
width: 44px; height: 44px;
background: linear-gradient(135deg, var(--accent3), var(--accent2));
border-radius: 11px; font-size: 22px;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 28px rgba(255, 45, 120, 0.35);
animation: logoPulse 3s ease-in-out infinite;
}
@keyframes logoPulse {
0%, 100% { box-shadow: 0 0 22px rgba(255, 45, 120, 0.3); }
50% { box-shadow: 0 0 44px rgba(255, 45, 120, 0.6); }
}
.header h1 {
font-family: 'Orbitron', sans-serif; font-size: 20px; font-weight: 700; letter-spacing: 3px;
background: linear-gradient(90deg, var(--accent3), var(--accent2));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.header-sub { font-size: 10px; color: var(--text-secondary); letter-spacing: 2px; text-transform: uppercase; }
.header-right { display: flex; align-items: center; gap: 18px; }
.live-chip {
display: flex; align-items: center; gap: 8px;
font-size: 9px; letter-spacing: 1.5px; padding: 5px 12px; border-radius: 12px;
font-weight: 600; text-transform: uppercase;
background: rgba(255, 45, 120, 0.1); color: var(--accent3);
border: 1px solid rgba(255, 45, 120, 0.2);
}
.live-chip .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--accent3); box-shadow: 0 0 8px var(--accent3);
animation: blink 1.5s ease-in-out infinite;
}
.live-chip.live-chip-green {
background: rgba(0, 255, 136, 0.1); color: var(--accent4);
border: 1px solid rgba(0, 255, 136, 0.2);
}
.live-chip.live-chip-green .dot {
background: var(--accent4); box-shadow: 0 0 8px var(--accent4);
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
.section-title {
font-family: 'Orbitron', sans-serif; font-size: 13px; font-weight: 600; letter-spacing: 2px;
margin-bottom: 16px;
background: linear-gradient(90deg, var(--accent), var(--accent2));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
display: inline-block;
}
.counters-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
}
.panel {
background: var(--glass);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glow); border-radius: var(--radius);
padding: 24px 26px;
position: relative; overflow: hidden;
}
.counter-card {
text-align: center;
padding: 20px 0;
}
.counter-card .counter-val {
font-family: 'Orbitron', sans-serif; font-size: 56px; font-weight: 700;
background: linear-gradient(180deg, #fff 0%, var(--accent) 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
transition: all 0.15s ease;
}
.counter-card.recount .counter-val {
background: linear-gradient(180deg, #fff 0%, var(--accent3) 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.counter-card .counter-label { font-size: 10px; color: var(--text-secondary); letter-spacing: 2px; text-transform: uppercase; }
.counter-card .counter-sub {
font-size: 9px; color: var(--text-secondary); margin-top: 6px;
}
.counter-card .counter-sub .batch-num {
font-family: 'Orbitron', sans-serif; font-size: 18px; font-weight: 700;
color: var(--accent);
}
.counter-status {
font-size: 9px; letter-spacing: 1px; margin-top: 10px;
padding: 4px 12px; border-radius: 6px; display: inline-block;
}
.counter-status.connected {
color: var(--accent4); background: rgba(0, 255, 136, 0.06);
}
.counter-status.disconnected {
color: var(--accent3); background: rgba(255, 45, 120, 0.06);
}
.bottom-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
}
.video-panel {
background: var(--glass);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glow); border-radius: var(--radius);
padding: 12px;
position: relative; overflow: hidden;
min-height: 340px;
display: flex; flex-direction: column;
}
.video-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px; padding: 0 12px;
}
.video-container {
flex: 1; display: flex; align-items: center; justify-content: center;
background: #000; border-radius: 10px;
overflow: hidden; position: relative;
aspect-ratio: 4 / 3; max-height: 480px;
}
.video-container img {
width: 100%; height: 100%; object-fit: contain; display: block;
}
.video-placeholder {
text-align: center; color: var(--text-secondary);
padding: 40px;
}
.video-placeholder .vp-icon {
font-size: 48px; opacity: 0.15; margin-bottom: 12px;
}
.video-placeholder .vp-text {
font-size: 11px; letter-spacing: 1px;
}
.video-placeholder .vp-hint {
font-size: 9px; margin-top: 8px; opacity: 0.5;
}
.video-status {
font-size: 9px; letter-spacing: 1px;
display: flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 6px;
}
.video-status.connected {
color: var(--accent4); background: rgba(0, 255, 136, 0.06);
}
.video-status.stopped {
color: var(--text-secondary); background: var(--cell-bg);
}
.video-toggle {
background: none; border: 1px solid var(--cell-border);
border-radius: 8px; padding: 6px 14px; cursor: pointer;
font-family: 'JetBrains Mono', monospace;
font-size: 10px; letter-spacing: 1px;
color: var(--text-secondary);
transition: border-color 0.3s, color 0.3s;
}
.video-toggle:hover { border-color: var(--accent); color: var(--text-primary); }
.video-toggle.active { border-color: var(--accent4); color: var(--accent4); }
.file-panel {
background: var(--glass);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glow); border-radius: var(--radius);
padding: 20px;
display: flex; flex-direction: column;
}
.file-panel .fp-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px;
}
.upload-area {
border: 2px dashed var(--cell-border);
border-radius: 10px;
padding: 16px;
margin-bottom: 12px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.upload-area:hover {
border-color: var(--accent2);
background: rgba(123, 47, 255, 0.04);
}
.upload-area.dragover {
border-color: var(--accent);
background: rgba(0, 240, 255, 0.04);
}
.upload-area .upload-icon {
font-size: 28px;
color: var(--text-secondary);
opacity: 0.4;
margin-bottom: 6px;
}
.upload-area .upload-text {
font-size: 10px;
color: var(--text-secondary);
letter-spacing: 1px;
}
.upload-area .upload-hint {
font-size: 8px;
color: var(--text-secondary);
opacity: 0.5;
margin-top: 4px;
}
.upload-row {
display: flex;
align-items: center; gap: 8px;
margin-bottom: 14px;
}
.upload-row .file-name {
flex: 1;
font-size: 9px;
color: var(--text-secondary);
word-break: break-all;
padding: 6px 10px;
background: var(--cell-bg);
border: 1px solid var(--cell-border);
border-radius: 6px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.upload-row .file-name.has-file {
color: var(--accent4);
}
.file-list {
flex: 1; overflow-y: auto; max-height: 300px;
}
.file-item {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 12px; margin-bottom: 4px;
background: var(--cell-bg);
border: 1px solid var(--cell-border); border-radius: 8px;
cursor: pointer; transition: border-color 0.2s, background 0.2s;
}
.file-item:hover { border-color: var(--border-glow); background: var(--card-bg); }
.file-item.selected {
border-color: var(--accent2);
background: rgba(123, 47, 255, 0.08);
}
.file-item.streaming {
border-color: var(--accent3);
background: rgba(255, 45, 120, 0.08);
}
.file-item .fi-name {
font-size: 10px; color: var(--text-primary);
word-break: break-all; flex: 1;
}
.file-item .fi-size {
font-size: 8px; color: var(--text-secondary);
margin-left: 8px; white-space: nowrap;
}
.file-item .fi-icon {
font-size: 14px; margin-right: 8px;
color: var(--accent3);
}
.file-item.streaming .fi-icon { color: var(--accent4); animation: blink 1.5s ease-in-out infinite; }
.file-item .fi-delete {
background: none; border: 1px solid var(--cell-border);
border-radius: 5px; color: var(--text-secondary);
cursor: pointer; padding: 3px 7px; margin-left: 6px;
font-size: 9px; transition: border-color 0.2s, color 0.2s;
}
.file-item .fi-delete:hover {
border-color: var(--accent3); color: var(--accent3);
}
.upload-status {
font-size: 9px; letter-spacing: 1px;
margin-top: 8px; text-align: center;
color: var(--text-secondary);
}
.upload-status.success { color: var(--accent4); }
.upload-status.error { color: var(--accent3); }
.btn {
font-family: 'Orbitron', sans-serif; font-size: 10px; letter-spacing: 2px;
padding: 8px 16px; border-radius: 7px; cursor: pointer; font-weight: 600;
border: none; transition: opacity 0.3s, box-shadow 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #000;
}
.btn-primary:hover { opacity: 0.85; box-shadow: 0 0 20px rgba(0, 240, 255, 0.3); }
.btn-danger {
background: linear-gradient(135deg, var(--accent3), var(--accent2));
color: #fff;
}
.btn-danger:hover { opacity: 0.85; box-shadow: 0 0 20px rgba(255, 45, 120, 0.4); }
.btn-ghost {
background: var(--cell-bg); color: var(--text-secondary);
border: 1px solid var(--cell-border);
}
.btn-ghost:hover { border-color: var(--accent); color: var(--text-primary); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
.btn-row { display: flex; gap: 8px; margin-top: 12px; }
.footer {
margin-top: 24px; text-align: center;
font-size: 9px; letter-spacing: 2px; color: var(--text-secondary);
text-transform: uppercase;
}
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--text-secondary); border-radius: 3px; }
@media (max-width: 900px) {
.counters-row, .bottom-row { grid-template-columns: 1fr; }
.container { padding: 12px 8px; }
.header { flex-direction: column; gap: 10px; padding: 14px 16px; }
.counter-card .counter-val { font-size: 40px; }
}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="container">
<div class="header">
<div class="header-left">
<div class="logo-icon">&#10006;</div>
<div>
<h1>{{ site_name }} Recounting Upload</h1>
<div class="header-sub">Upload MP4 &amp; recount</div>
</div>
</div>
<div class="header-right">
<div class="live-chip live-chip-green">
<span class="dot"></span>
<span>LIVE</span>
</div>
<div class="live-chip">
<span class="dot"></span>
<span>RECOUNT</span>
</div>
</div>
</div>
<div class="counters-row">
<div class="panel">
<span class="section-title" id="live-title">&#9673; Live Counter</span>
<div class="counter-card">
<div class="counter-val" id="live-count">--</div>
<div class="counter-label" id="live-label">Objects Counted</div>
<div class="counter-sub">Batch <span class="batch-num" id="live-batch">--</span> &middot; <span id="live-time">--</span></div>
<div class="counter-status disconnected" id="live-status">
no file selected
</div>
</div>
</div>
<div class="panel">
<span class="section-title">&#9673; Recount Progress</span>
<div class="counter-card recount">
<div class="counter-val" id="recount-count">--</div>
<div class="counter-label">Objects Recounted</div>
<div class="counter-sub"><span id="recount-time">--</span></div>
<div class="counter-status disconnected" id="recount-status">
waiting...
</div>
</div>
</div>
</div>
<div class="bottom-row">
<div class="video-panel">
<div class="video-header">
<span class="section-title" style="margin-bottom:0;">&#9673; Preview</span>
<div style="display:flex;align-items:center;gap:10px;">
<span class="video-status stopped" id="video-status">
<span style="width:6px;height:6px;border-radius:50%;background:var(--text-secondary);"></span>
stopped
</span>
<button class="video-toggle" id="videoToggle" onclick="togglePreview()">STREAM OFF</button>
</div>
</div>
<div class="video-container" id="video-container">
<div class="video-placeholder" id="video-placeholder">
<div class="vp-icon">&#9654;</div>
<div class="vp-text">Upload an MP4 and start recount</div>
<div class="vp-hint">Preview via recount node live feed</div>
</div>
<img id="stream-img" alt="Stream" style="display:none;" />
</div>
</div>
<div class="file-panel">
<div class="fp-header">
<span class="section-title" style="margin-bottom:0;font-size:11px;">&#9673; Upload MP4</span>
<button class="btn btn-ghost" onclick="loadUploads()" style="font-size:8px;padding:4px 8px;">
<i class="fas fa-sync-alt"></i>
</button>
</div>
<div class="upload-area" id="upload-drop" onclick="document.getElementById('upload-file-input').click()">
<div class="upload-icon">&#8682;</div>
<div class="upload-text">Click or drop .mp4 file here</div>
<div class="upload-hint">Max 512 MB</div>
</div>
<div class="upload-row">
<span class="file-name" id="pending-filename">no file chosen</span>
<button class="btn btn-ghost" id="btn-upload" onclick="uploadFile()">UPLOAD</button>
</div>
<div class="upload-status" id="upload-status"></div>
<input type="file" id="upload-file-input" accept=".mp4" style="display:none"
onchange="onFilePicked(event)">
<div class="file-list" id="file-list">
<div style="font-size:10px;color:var(--text-secondary);text-align:center;padding:20px;">
No uploads yet
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="btn-start" onclick="startRecount()" disabled>
&#9654; START RECOUNT
</button>
<button class="btn btn-danger" id="btn-stop" onclick="stopRecount()" disabled>
&#9632; STOP
</button>
<button class="btn btn-ghost" id="btn-download" onclick="downloadFile()" disabled>
<i class="fas fa-download"></i>
</button>
<button class="btn btn-ghost" id="btn-delete" onclick="deleteUpload()" disabled>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
<div class="footer">{{ site_name }} Recounting Upload Dashboard</div>
</div>
<script>
let selectedFile = null;
let streaming = false;
let streamUrl = '';
let pendingUploadFile = null;
let batchResultLoaded = false;
document.addEventListener('DOMContentLoaded', () => {
loadRecountProgress();
loadUploads();
syncState();
setInterval(loadRecountProgress, 500);
setInterval(loadUploads, 30000);
setInterval(syncState, 1000);
var drop = document.getElementById('upload-drop');
drop.addEventListener('dragover', function(e) { e.preventDefault(); drop.classList.add('dragover'); });
drop.addEventListener('dragleave', function() { drop.classList.remove('dragover'); });
drop.addEventListener('drop', function(e) {
e.preventDefault();
drop.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
setPendingFile(e.dataTransfer.files[0]);
}
});
});
function onFilePicked(event) {
if (event.target.files.length > 0) {
setPendingFile(event.target.files[0]);
}
event.target.value = '';
}
function setPendingFile(file) {
if (!file.name.toLowerCase().endsWith('.mp4')) {
showUploadStatus('Only .mp4 files allowed', 'error');
return;
}
pendingUploadFile = file;
document.getElementById('pending-filename').textContent = file.name;
document.getElementById('pending-filename').classList.add('has-file');
document.getElementById('upload-status').textContent = '';
document.getElementById('upload-status').className = 'upload-status';
}
function showUploadStatus(msg, cls) {
var el = document.getElementById('upload-status');
el.textContent = msg;
el.className = 'upload-status ' + (cls || '');
}
async function uploadFile() {
if (!pendingUploadFile) {
showUploadStatus('Pick a file first', 'error');
return;
}
var btn = document.getElementById('btn-upload');
btn.disabled = true;
btn.textContent = 'UPLOADING...';
showUploadStatus('Uploading...', '');
try {
var form = new FormData();
form.append('file', pendingUploadFile);
var res = await fetch('/api/upload', { method: 'POST', body: form });
var data = await res.json();
if (!data.success) {
showUploadStatus(data.error || 'Upload failed', 'error');
btn.disabled = false;
btn.textContent = 'UPLOAD';
return;
}
showUploadStatus('Upload complete: ' + data.name, 'success');
selectedFile = data.path;
document.getElementById('btn-start').disabled = false;
document.getElementById('btn-download').disabled = false;
document.getElementById('btn-delete').disabled = false;
document.getElementById('pending-filename').textContent = 'no file chosen';
document.getElementById('pending-filename').classList.remove('has-file');
pendingUploadFile = null;
loadUploads();
loadBatchResult(data.path);
} catch (err) {
showUploadStatus('Upload error: ' + err.message, 'error');
}
btn.disabled = false;
btn.textContent = 'UPLOAD';
}
function selectFile(path, name) {
if (selectedFile === path) {
selectedFile = null;
batchResultLoaded = false;
document.getElementById('btn-start').disabled = true;
document.getElementById('btn-download').disabled = true;
document.getElementById('btn-delete').disabled = true;
document.getElementById('live-title').innerHTML = '&#9673; Live Counter';
document.getElementById('live-count').textContent = '--';
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-batch').textContent = '--';
document.getElementById('live-time').textContent = '--';
document.getElementById('live-status').textContent = 'no file selected';
document.getElementById('live-status').className = 'counter-status disconnected';
updateFileSelection();
return;
}
selectedFile = path;
document.getElementById('btn-start').disabled = false;
document.getElementById('btn-download').disabled = false;
document.getElementById('btn-delete').disabled = false;
updateFileSelection();
loadBatchResult(path);
}
function updateFileSelection() {
document.querySelectorAll('.file-item').forEach(function(el) {
var p = el.getAttribute('data-path');
if (p === selectedFile) {
el.classList.add('selected');
} else {
el.classList.remove('selected');
}
});
}
async function loadUploads() {
var container = document.getElementById('file-list');
try {
var res = await fetch('/api/uploads');
var files = await res.json();
if (!files || files.length === 0) {
container.innerHTML = '<div style="font-size:10px;color:var(--text-secondary);text-align:center;padding:20px;">No uploads yet</div>';
return;
}
container.innerHTML = files.map(function(f) {
var sel = selectedFile === f.path;
var str = streaming && selectedFile === f.path;
var cls = 'file-item';
if (str) cls += ' streaming';
else if (sel) cls += ' selected';
return '<div class="' + cls + '" onclick="event.stopPropagation(); selectFile(\'' +
f.path.replace(/'/g, "\\'") + '\', \'' + f.name.replace(/'/g, "\\'") + '\')" data-path="' +
f.path.replace(/"/g, '&quot;') + '">' +
'<span class="fi-icon">' + (str ? '&#9673;' : '&#9654;') + '</span>' +
'<span class="fi-name">' + f.name + '</span>' +
'<span class="fi-size">' + formatSize(f.size) + '</span>' +
'<button class="fi-delete" onclick="event.stopPropagation(); deleteUploadRecord(\'' +
f.path.replace(/'/g, "\\'") + '\')">&#10005;</button>' +
'</div>';
}).join('');
} catch (err) {
console.error('Failed to load uploads:', err);
}
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(1) + ' GB';
}
async function startRecount() {
if (!selectedFile) return;
var btnStart = document.getElementById('btn-start');
btnStart.disabled = true;
btnStart.textContent = 'CONNECTING...';
lastRecountCount = null;
lastRecountTime = null;
document.getElementById('recount-count').textContent = '0';
document.getElementById('recount-time').textContent = 'resetting...';
document.getElementById('recount-status').textContent = 'resetting';
document.getElementById('recount-status').className = 'counter-status disconnected';
try {
var res = await fetch('/api/start-recount', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: selectedFile })
});
var data = await res.json();
if (!data.success) {
alert('Failed: ' + (data.error || 'unknown'));
btnStart.disabled = false;
btnStart.textContent = '\u25b6 START RECOUNT';
return;
}
streamUrl = data.stream_url;
streaming = true;
selectedFile = data.path;
document.getElementById('btn-stop').disabled = false;
btnStart.textContent = 'STREAMING';
btnStart.classList.add('btn-ghost');
document.getElementById('video-status').innerHTML =
'<span style="width:6px;height:6px;border-radius:50%;background:var(--accent4);box-shadow:0 0 6px var(--accent4);animation:blink 1.5s ease-in-out infinite;"></span> streaming ' + data.file;
document.getElementById('video-status').className = 'video-status connected';
} catch (err) {
alert('Error: ' + err.message);
btnStart.disabled = false;
btnStart.textContent = '\u25b6 START RECOUNT';
}
}
async function stopRecount() {
try {
await fetch('/api/stop-recount', { method: 'POST' });
} catch (err) {
alert('Failed to stop recount');
return;
}
streaming = false;
streamUrl = '';
selectedFile = null;
batchResultLoaded = false;
var img = document.getElementById('stream-img');
var placeholder = document.getElementById('video-placeholder');
img.src = '';
img.style.display = 'none';
if (placeholder) placeholder.style.display = '';
var btnStart = document.getElementById('btn-start');
var btnStop = document.getElementById('btn-stop');
btnStart.disabled = true;
btnStart.textContent = '\u25b6 START RECOUNT';
btnStart.classList.remove('btn-ghost');
btnStop.disabled = true;
document.getElementById('btn-download').disabled = true;
document.getElementById('btn-delete').disabled = true;
document.getElementById('video-status').innerHTML =
'<span style="width:6px;height:6px;border-radius:50%;background:var(--text-secondary);"></span> stopped';
document.getElementById('video-status').className = 'video-status stopped';
document.getElementById('live-title').innerHTML = '&#9673; Live Counter';
document.getElementById('live-count').textContent = '--';
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-batch').textContent = '--';
document.getElementById('live-time').textContent = '--';
document.getElementById('live-status').textContent = 'no file selected';
document.getElementById('live-status').className = 'counter-status disconnected';
resetVideoToggle();
loadUploads();
}
function togglePreview() {
var btn = document.getElementById('videoToggle');
var img = document.getElementById('stream-img');
var placeholder = document.getElementById('video-placeholder');
if (!streamUrl) return;
if (img.style.display === 'block') {
img.src = '';
img.style.display = 'none';
if (placeholder) placeholder.style.display = '';
btn.textContent = 'STREAM OFF';
btn.classList.remove('active');
} else {
img.src = streamUrl + '?' + Date.now();
img.style.display = 'block';
if (placeholder) placeholder.style.display = 'none';
btn.textContent = 'STREAM ON';
btn.classList.add('active');
}
}
function resetVideoToggle() {
var btn = document.getElementById('videoToggle');
var img = document.getElementById('stream-img');
var placeholder = document.getElementById('video-placeholder');
img.src = '';
img.style.display = 'none';
if (placeholder) placeholder.style.display = '';
btn.textContent = 'STREAM OFF';
btn.classList.remove('active');
}
async function syncState() {
try {
var res = await fetch('/api/state');
var data = await res.json();
applyState(data);
} catch (err) {}
}
function applyState(data) {
if (!data) return;
if (data.file) {
selectedFile = data.file;
document.getElementById('btn-start').disabled = data.streaming;
document.getElementById('btn-download').disabled = false;
document.getElementById('btn-delete').disabled = false;
} else {
selectedFile = null;
batchResultLoaded = false;
document.getElementById('btn-start').disabled = true;
document.getElementById('btn-download').disabled = true;
document.getElementById('btn-delete').disabled = true;
document.getElementById('live-title').innerHTML = '&#9673; Live Counter';
document.getElementById('live-count').textContent = '--';
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-batch').textContent = '--';
document.getElementById('live-time').textContent = '--';
document.getElementById('live-status').textContent = 'no file selected';
document.getElementById('live-status').className = 'counter-status disconnected';
}
if (data.streaming && data.file) {
streaming = true;
streamUrl = data.stream_url;
document.getElementById('btn-stop').disabled = false;
document.getElementById('btn-start').disabled = true;
document.getElementById('btn-start').textContent = 'STREAMING';
document.getElementById('btn-start').classList.add('btn-ghost');
document.getElementById('video-status').innerHTML =
'<span style="width:6px;height:6px;border-radius:50%;background:var(--accent4);box-shadow:0 0 6px var(--accent4);animation:blink 1.5s ease-in-out infinite;"></span> streaming ' + (data.file_name || '');
document.getElementById('video-status').className = 'video-status connected';
loadBatchResult(data.file);
} else if (!data.streaming && data.file) {
loadBatchResult(data.file);
}
}
async function loadBatchResult(filePath) {
if (!filePath) {
batchResultLoaded = false;
document.getElementById('live-title').innerHTML = '&#9673; Live Counter';
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-count').textContent = '--';
document.getElementById('live-batch').textContent = '--';
document.getElementById('live-time').textContent = '--';
document.getElementById('live-status').textContent = 'no file selected';
document.getElementById('live-status').className = 'counter-status disconnected';
return;
}
try {
var res = await fetch('/api/batch-result?file=' + encodeURIComponent(filePath));
var data = await res.json();
if (!data.success) {
document.getElementById('live-title').innerHTML = '&#9673; Live Counter';
document.getElementById('live-count').textContent = '--';
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-batch').textContent = '--';
document.getElementById('live-time').textContent = data.error || 'not found';
document.getElementById('live-status').textContent = 'not found';
document.getElementById('live-status').className = 'counter-status disconnected';
batchResultLoaded = false;
return;
}
batchResultLoaded = true;
document.getElementById('live-title').innerHTML = '&#9673; Batch Result';
document.getElementById('live-count').textContent = data.count.toLocaleString();
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-batch').textContent = data.batch_number;
document.getElementById('live-time').textContent = data.date;
var startStr = data.start_time ? new Date(data.start_time).toLocaleTimeString('en-US', {hour12: false, hour:'2-digit', minute:'2-digit'}) : '--';
var endStr = data.end_time ? new Date(data.end_time).toLocaleTimeString('en-US', {hour12: false, hour:'2-digit', minute:'2-digit'}) : '--';
document.getElementById('live-status').textContent = 'recorded \u00b7 ' + startStr + ' \u2013 ' + endStr;
document.getElementById('live-status').className = 'counter-status connected';
} catch (err) {
console.error('Failed to load batch result:', err);
batchResultLoaded = false;
}
}
async function deleteUploadRecord(fpath) {
var ok = confirm('Delete ' + (fpath.split('/').pop()) + '?');
if (!ok) return;
try {
var res = await fetch('/api/delete-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: fpath })
});
var data = await res.json();
if (!data.success) {
alert('Delete failed: ' + (data.error || 'unknown'));
return;
}
if (selectedFile === fpath) {
selectedFile = null;
batchResultLoaded = false;
document.getElementById('btn-start').disabled = true;
document.getElementById('btn-download').disabled = true;
document.getElementById('btn-delete').disabled = true;
document.getElementById('live-title').innerHTML = '&#9673; Live Counter';
document.getElementById('live-count').textContent = '--';
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-batch').textContent = '--';
document.getElementById('live-time').textContent = '--';
document.getElementById('live-status').textContent = 'no file selected';
document.getElementById('live-status').className = 'counter-status disconnected';
}
loadUploads();
} catch (err) {
alert('Error: ' + err.message);
}
}
function deleteUpload() {
if (!selectedFile) return;
deleteUploadRecord(selectedFile);
}
function downloadFile() {
if (!selectedFile) return;
var a = document.createElement('a');
a.href = '/api/download-mp4?file=' + encodeURIComponent(selectedFile);
a.download = selectedFile.split('/').pop();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
var lastRecountCount = null;
var lastRecountTime = null;
async function loadRecountProgress() {
var countEl = document.getElementById('recount-count');
var timeEl = document.getElementById('recount-time');
var statusEl = document.getElementById('recount-status');
try {
var res = await fetch('/api/recount-progress');
var data = await res.json();
if (!data.success) {
if (lastRecountCount === null) {
countEl.textContent = '--';
timeEl.textContent = data.error || 'unreachable';
}
statusEl.className = 'counter-status disconnected';
statusEl.textContent = 'completed';
return;
}
lastRecountCount = data.count.toLocaleString();
countEl.textContent = lastRecountCount;
if (data.last_detection_time) {
var d = new Date(data.last_detection_time);
lastRecountTime = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
timeEl.textContent = lastRecountTime;
} else if (lastRecountTime) {
timeEl.textContent = lastRecountTime;
} else {
timeEl.textContent = 'waiting...';
}
statusEl.className = 'counter-status connected';
statusEl.textContent = 'connected';
} catch (err) {
if (lastRecountCount === null) {
countEl.textContent = '--';
timeEl.textContent = 'unreachable';
}
statusEl.className = 'counter-status disconnected';
statusEl.textContent = 'completed';
}
}
</script>
</body>
</html>