Add OUTPUT_DIR browser + copy-on-load with progress to upload dashboard
LOAD MP4 on an OUTPUT_DIR file copies it into UPLOAD_DIR (chunked, progress via /api/copy-progress) before spawning RECOUNT_CMD; collision in UPLOAD_DIR fails with 400. New read-only folder-grouped browser mirrors recounting_dashboard.py. Docs updated.
This commit is contained in:
1 parent
634d649069
commit
fccc285ab3
6 files changed
+377
-8
No files matched your search
@@ -47,6 +47,7 @@ PYTHONNOUSERSITE=1 venv/bin/python recounting_dashboard_upload.py
|
||||
- `counter_live.py` (TensorRT) is Jetson-only and won't work on RK3588.
|
||||
- **Reset flow**: `POST /api/reset` → deletes state JSON + touches `{SHM_DIR}/.reset`. `batch_store._check_reset_signal()` watches this marker and clears in-memory state on next crossing.
|
||||
- **Recount continue flow** (upload dashboard, port 5003): `POST /api/load-mp4 {path}` kills the old process, spawns `RECOUNT_CMD` (loads + pauses), and POSTs `/api/reset` to the recount node. `POST /api/start-recount` (no body, requires a loaded file) touches `{SHM_DIR}/.continue`; the C++ process consumes that marker to begin counting. The dashboard never clears the marker.
|
||||
- **OUTPUT_DIR browser + copy-on-load** (upload dashboard, port 5003): `GET /api/mp4-files` lists MP4s from `OUTPUT_DIR` folder-grouped (mirrors `recounting_dashboard.py`'s `/api/mp4-files` shape). Selecting one and pressing LOAD MP4 makes `POST /api/load-mp4` copy it into `UPLOAD_DIR` first (chunked, progress reported via `GET /api/copy-progress` → `{active,name,total,done,pct}`, frontend polls every 250 ms to drive the progress bar). Name collision in `UPLOAD_DIR` → 400 error (no overwrite). The OUTPUT_DIR browser is read-only (no delete).
|
||||
- **Recount STOP sweep**: `POST /api/stop-recount` kills the tracked process group (`killpg` TERM→5s→KILL) then sweeps the host via `/proc` for any process whose argv matches the `RECOUNT_CMD` binary name (basename of the first token, so it also catches orphans from a dashboard restart); TERM→`_SWEEP_GRACE_SEC`→KILL with `_SWEEP_MAX_RETRIES` retries, all under `_recount_lock`. Returns `{success, killed, remaining}`; `remaining` is 0 in the normal path, and survivors (e.g. D-state) are logged rather than force-hanging.
|
||||
- **Upload filename contract**: uploads are expected as `batch_XX_YYYYMMDD_HHmmSS.mp4` (XX = batch number, timestamp = date). `recounting_dashboard_upload.py` parses both to query the live API's `/api/day-detail/<YYYY-MM-DD>` for the recorded count.
|
||||
- **`RECOUNT_CMD`** in the upload dashboard is a template with a `{path}` placeholder, e.g. `bytetrack-counter-cpp config.env --source {path}`; the uploaded file path is `shlex.quote()`d before substitution, and the command is spawned by **LOAD MP4** (loads + pauses until `.continue` is touched).
|
||||
|
||||
@@ -69,6 +69,7 @@ env: GO2RTC_STREAM_NAME → go2rtc stream name for recount preview (default "rec
|
||||
```
|
||||
env: RECOUNTING_UPLOAD_PORT → port for upload UI (default 5003)
|
||||
env: UPLOAD_DIR → dir for uploaded MP4s (default <repo>/uploads)
|
||||
env: OUTPUT_DIR → dir scanned for "From Output Directory" browser (default /opt/bytetrack-counter)
|
||||
env: RECOUNT_CMD → replay command template with {path} placeholder (default "bytetrack-counter config.env --source {path}")
|
||||
env: RECOUNTING_UPLOAD_TEMPLATE → template (default recounting_upload_lamborghini.html)
|
||||
env: LIVE_DASHBOARD_URL → header link to live counter dashboard (default empty → hidden)
|
||||
@@ -123,8 +124,10 @@ api: POST /api/stop-recount → kill ffmpeg + clear current file
|
||||
api: GET / → upload recounting HTML (template via RECOUNTING_UPLOAD_TEMPLATE env var)
|
||||
api: POST /api/upload → multipart .mp4 (≤512MB), saved as original filename in UPLOAD_DIR; returns {name, path, size}
|
||||
api: GET /api/uploads → list uploaded MP4s sorted by mtime desc → [ {name, path, size, mtime} ]
|
||||
api: GET /api/mp4-files → list OUTPUT_DIR MP4s grouped by folder (date), files sorted by batch # → [ {folder, files:[{name, path, size, mtime}]} ]
|
||||
api: GET /api/copy-progress → {active:false} idle | {active, name, total, done, pct} during an in-progress copy
|
||||
api: POST /api/delete-upload {path} → delete uploaded MP4 (path restricted to UPLOAD_DIR)
|
||||
api: POST /api/load-mp4 {path} → kill old process, spawn RECOUNT_CMD (path shlex-quoted) paused, then reset recount API
|
||||
api: POST /api/load-mp4 {path} → if path outside UPLOAD_DIR: collision check (same-name in UPLOAD_DIR → 400), else copy (chunked, progress-reporting) into UPLOAD_DIR first; then kill old process, spawn RECOUNT_CMD (copied path shlex-quoted) paused, reset recount API; returns copied path
|
||||
api: POST /api/start-recount → require loaded file, touch {SHM_DIR}/.continue to signal C++ process to begin counting
|
||||
api: POST /api/stop-recount → killpg tracked group + host-wide /proc sweep of RECOUNT_CMD binary (TERM→grace→KILL, verify loop); returns {success, killed, remaining}
|
||||
api: GET /api/state → {streaming, file, file_name, stream_url, batch_number, batch_date}
|
||||
@@ -163,6 +166,8 @@ V21: upload filename batch_XX_YYYYMMDD_HHmmSS.mp4 → batch# + date parsed; batc
|
||||
V22: preview auto-on once per streaming session — enablePreview() on START RECOUNT + applyState streaming branch guarded by previewAutoOn flag; manual toggle persists until next session (flag reset only on stop); img.onerror retries cache-busted src while streaming
|
||||
V23: .continue marker flow — LOAD MP4 spawns RECOUNT_CMD paused; START RECOUNT touches {SHM_DIR}/.continue; C++ process consumes marker to begin counting; dashboard never clears it (LOAD alone does not count)
|
||||
V24: STOP → killpg tracked group then host-wide /proc sweep of RECOUNT_CMD binary (basename of first token); sweep runs under _recount_lock, TERM→_SWEEP_GRACE_SEC→KILL, bounded retries; returns {success, killed, remaining} with remaining==0 in normal path; survivors (D-state) logged, never force-hang
|
||||
V25: LOAD MP4 on an OUTPUT_DIR file → copy into UPLOAD_DIR (original untouched); copy runs server-side, chunked, reports {done,total} to _copy_progress under _copy_lock; name collision in UPLOAD_DIR → 400 error, no overwrite
|
||||
V26: OUTPUT_DIR browser → read-only, folder-grouped via /api/mp4-files (mirrors 5002 shape); no delete button on OUTPUT_DIR rows
|
||||
```
|
||||
|
||||
## §T — Tasks
|
||||
@@ -188,6 +193,7 @@ T16|x|add cross-dashboard header links — UPLOAD_DASHBOARD_URL (live→upload)
|
||||
T17|x|auto-on live preview when recount running — enablePreview() on START + applyState; onerror retry while streaming|V22
|
||||
T18|x|split recount flow — /api/load-mp4 spawns paused RECOUNT_CMD, /api/start-recount touches {SHM_DIR}/.continue; C++ consumes marker|V23
|
||||
T19|x|hard-kill STOP — sweep all RECOUNT_CMD-binary processes via /proc, verify remaining==0, report killed/remaining|V24
|
||||
T20|x|add OUTPUT_DIR browser to upload dashboard — /api/mp4-files listing, copy-on-load with progress polling into UPLOAD_DIR, collision guard|V25,V26
|
||||
```
|
||||
|
||||
## §B — Bugs
|
||||
|
||||
@@ -185,6 +185,9 @@ RECOUNT_STREAM_NAME=stream_1
|
||||
RECOUNTING_UPLOAD_PORT=5003
|
||||
# Directory where uploaded MP4 files are stored
|
||||
UPLOAD_DIR=/opt/bytetrack-counter/uploads
|
||||
# Directory scanned for the "From Output Directory" file browser (folder-grouped MP4s).
|
||||
# Selecting one of those files and pressing LOAD MP4 copies it into UPLOAD_DIR first.
|
||||
OUTPUT_DIR=/opt/bytetrack-counter
|
||||
# Command launched by LOAD MP4 (bytetrack-counter-cpp); {path} is the uploaded MP4.
|
||||
# The process loads + pauses until START RECOUNT touches {SHM_DIR}/.continue.
|
||||
RECOUNT_CMD=bytetrack-counter-cpp config.env --source {path}
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -32,6 +33,7 @@ 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")))
|
||||
OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "/opt/bytetrack-counter"))
|
||||
LIVE_API_URL = os.getenv("LIVE_API_URL", "http://localhost:5000")
|
||||
RECOUNT_API_URL = os.getenv("RECOUNT_API_URL", "http://localhost:5001")
|
||||
|
||||
@@ -60,6 +62,9 @@ _recount_proc = None
|
||||
_recount_lock = threading.Lock()
|
||||
_current_file = None
|
||||
|
||||
_copy_progress = {}
|
||||
_copy_lock = threading.Lock()
|
||||
|
||||
|
||||
def _parse_filename(filename):
|
||||
m = _FILENAME_RE.search(filename)
|
||||
@@ -172,7 +177,7 @@ def _folder_date(filepath, base):
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template(TEMPLATE, site_name=SITE_NAME, upload_dir=str(UPLOAD_DIR), live_dashboard_url=LIVE_DASHBOARD_URL)
|
||||
return render_template(TEMPLATE, site_name=SITE_NAME, upload_dir=str(UPLOAD_DIR), output_dir=str(OUTPUT_DIR), live_dashboard_url=LIVE_DASHBOARD_URL)
|
||||
|
||||
|
||||
@app.route("/api/live-progress")
|
||||
@@ -206,6 +211,55 @@ def api_uploads():
|
||||
return jsonify(files)
|
||||
|
||||
|
||||
@app.route("/api/mp4-files")
|
||||
def api_mp4_files():
|
||||
folders = {}
|
||||
output = Path(OUTPUT_DIR)
|
||||
if not output.exists():
|
||||
return jsonify([])
|
||||
|
||||
for d in sorted(output.iterdir(), reverse=True):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
folder_name = d.name
|
||||
mp4s = sorted(d.glob("*.mp4"), key=lambda p: _parse_filename(p.name)[0] or 0)
|
||||
if not mp4s:
|
||||
continue
|
||||
files = []
|
||||
for f in mp4s:
|
||||
st = f.stat()
|
||||
files.append({
|
||||
"name": f.name,
|
||||
"path": str(f),
|
||||
"size": st.st_size,
|
||||
"mtime": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
||||
})
|
||||
folders[folder_name] = files
|
||||
|
||||
result = []
|
||||
for folder, files in sorted(folders.items(), reverse=True):
|
||||
result.append({"folder": folder, "files": files})
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/api/copy-progress")
|
||||
def api_copy_progress():
|
||||
with _copy_lock:
|
||||
p = dict(_copy_progress)
|
||||
if not p or not p.get("active"):
|
||||
return jsonify({"active": False})
|
||||
total = p.get("total") or 0
|
||||
done = p.get("done") or 0
|
||||
pct = round(done * 100.0 / total) if total else 0
|
||||
return jsonify({
|
||||
"active": True,
|
||||
"name": p.get("name"),
|
||||
"total": total,
|
||||
"done": done,
|
||||
"pct": pct,
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/upload", methods=["POST"])
|
||||
def api_upload():
|
||||
if "file" not in request.files:
|
||||
@@ -262,15 +316,65 @@ def _spawn_recount_locked(upload_path):
|
||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount loaded: {cmd_str}")
|
||||
|
||||
|
||||
def _copy_file_with_progress(src, dest):
|
||||
global _copy_progress
|
||||
total = os.path.getsize(src)
|
||||
done = 0
|
||||
with _copy_lock:
|
||||
_copy_progress = {
|
||||
"active": True,
|
||||
"name": Path(src).name,
|
||||
"total": total,
|
||||
"done": 0,
|
||||
"error": None,
|
||||
}
|
||||
try:
|
||||
with open(src, "rb") as fsrc, open(dest, "wb") as fdst:
|
||||
while True:
|
||||
buf = fsrc.read(1024 * 1024)
|
||||
if not buf:
|
||||
break
|
||||
fdst.write(buf)
|
||||
done += len(buf)
|
||||
with _copy_lock:
|
||||
_copy_progress["done"] = done
|
||||
shutil.copystat(src, dest)
|
||||
except Exception as e:
|
||||
with _copy_lock:
|
||||
_copy_progress["error"] = str(e)
|
||||
raise
|
||||
finally:
|
||||
with _copy_lock:
|
||||
_copy_progress["active"] = False
|
||||
|
||||
|
||||
@app.route("/api/load-mp4", methods=["POST"])
|
||||
def load_mp4():
|
||||
global _current_file
|
||||
data = request.get_json(force=True) or {}
|
||||
upload_path = data.get("path", "")
|
||||
if not upload_path:
|
||||
src_path = data.get("path", "")
|
||||
if not src_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
|
||||
if not os.path.isfile(src_path):
|
||||
return jsonify({"success": False, "error": f"File not found: {src_path}"}), 404
|
||||
|
||||
src_path = str(Path(src_path).resolve())
|
||||
upload_path = src_path
|
||||
if Path(src_path).parent != Path(UPLOAD_DIR).resolve():
|
||||
dest_path = str(Path(UPLOAD_DIR) / Path(src_path).name)
|
||||
if os.path.exists(dest_path):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"{Path(dest_path).name} already exists in uploads, delete it first",
|
||||
}), 400
|
||||
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
_copy_file_with_progress(src_path, dest_path)
|
||||
except Exception as e:
|
||||
with _copy_lock:
|
||||
err = _copy_progress.get("error") or str(e)
|
||||
return jsonify({"success": False, "error": f"Copy failed: {err}"}), 500
|
||||
upload_path = dest_path
|
||||
|
||||
with _recount_lock:
|
||||
_stop_recount_locked()
|
||||
@@ -430,5 +534,6 @@ if __name__ == "__main__":
|
||||
print(f"Live API: {LIVE_API_URL}")
|
||||
print(f"Recount API: {RECOUNT_API_URL}")
|
||||
print(f"Upload dir: {UPLOAD_DIR}")
|
||||
print(f"Output dir: {OUTPUT_DIR}")
|
||||
print(f"Recount command: {RECOUNT_CMD}")
|
||||
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)
|
||||
@@ -301,6 +301,38 @@
|
||||
.file-list {
|
||||
flex: 1; overflow-y: auto; max-height: 300px;
|
||||
}
|
||||
.output-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;
|
||||
}
|
||||
.output-panel .fp-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.output-list {
|
||||
flex: 1; overflow-y: auto; max-height: 300px;
|
||||
}
|
||||
.folder-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; margin-bottom: 4px;
|
||||
background: var(--cell-bg);
|
||||
border: 1px solid var(--cell-border); border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 10px; letter-spacing: 1px;
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.folder-header:hover { border-color: var(--border-glow); background: var(--card-bg); }
|
||||
.folder-header .folder-arrow {
|
||||
display: inline-block; color: var(--accent2); font-size: 10px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.folder-header.open .folder-arrow { transform: rotate(90deg); }
|
||||
.folder-body { display: none; padding-left: 8px; }
|
||||
.folder-body.open { display: block; }
|
||||
.file-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 12px; margin-bottom: 4px;
|
||||
@@ -544,6 +576,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-panel">
|
||||
<div class="fp-header">
|
||||
<span class="section-title" style="margin-bottom:0;font-size:11px;">◉ From Output Directory</span>
|
||||
<button class="btn btn-ghost" onclick="loadOutputFiles()" style="font-size:8px;padding:4px 8px;">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="file-list output-list" id="output-list">
|
||||
<div style="font-size:10px;color:var(--text-secondary);text-align:center;padding:20px;">
|
||||
No files found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">{{ site_name }} Recounting Upload Dashboard</div>
|
||||
</div>
|
||||
|
||||
@@ -560,10 +606,12 @@ let batchResultLoaded = false;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadRecountProgress();
|
||||
loadUploads();
|
||||
loadOutputFiles();
|
||||
syncState();
|
||||
|
||||
setInterval(loadRecountProgress, 500);
|
||||
setInterval(loadUploads, 30000);
|
||||
setInterval(loadOutputFiles, 30000);
|
||||
setInterval(syncState, 1000);
|
||||
|
||||
var drop = document.getElementById('upload-drop');
|
||||
@@ -739,6 +787,52 @@ async function loadUploads() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(fid) {
|
||||
var hdr = document.getElementById(fid + '-hdr');
|
||||
var body = document.getElementById(fid);
|
||||
if (hdr) hdr.classList.toggle('open');
|
||||
if (body) body.classList.toggle('open');
|
||||
}
|
||||
|
||||
async function loadOutputFiles() {
|
||||
var container = document.getElementById('output-list');
|
||||
try {
|
||||
var res = await fetch('/api/mp4-files');
|
||||
var folders = await res.json();
|
||||
if (!folders || folders.length === 0) {
|
||||
container.innerHTML = '<div style="font-size:10px;color:var(--text-secondary);text-align:center;padding:20px;">No MP4 files found</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = folders.map(function(g, gi) {
|
||||
var fid = 'ofolder-' + gi;
|
||||
return '<div class="folder-header" id="' + fid + '-hdr" onclick="toggleFolder(\'' + fid + '\')">' +
|
||||
'<span class="folder-arrow">▶</span>' +
|
||||
'<i class="fas fa-folder" style="color:var(--accent2);"></i>' +
|
||||
'<span>' + g.folder + '</span>' +
|
||||
'<span style="font-size:8px;color:var(--text-secondary);">(' + g.files.length + ')</span>' +
|
||||
'</div>' +
|
||||
'<div class="folder-body" id="' + fid + '">' +
|
||||
g.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, '"') + '">' +
|
||||
'<span class="fi-icon">' + (str ? '◉' : '▶') + '</span>' +
|
||||
'<span class="fi-name">' + f.name + '</span>' +
|
||||
'<span class="fi-size">' + formatSize(f.size) + '</span>' +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('Failed to load output files:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
@@ -779,13 +873,29 @@ async function loadMp4() {
|
||||
var btnLoad = document.getElementById('btn-load');
|
||||
btnLoad.disabled = true;
|
||||
btnLoad.textContent = 'LOADING...';
|
||||
var pollTimer = null;
|
||||
try {
|
||||
var res = await fetch('/api/load-mp4', {
|
||||
var req = fetch('/api/load-mp4', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: selectedFile })
|
||||
});
|
||||
|
||||
pollTimer = setInterval(async function() {
|
||||
try {
|
||||
var pr = await fetch('/api/copy-progress');
|
||||
var p = await pr.json();
|
||||
if (p.active) {
|
||||
showCopyProgress(p.pct, p.done, p.total, p.name);
|
||||
}
|
||||
} catch (err) {}
|
||||
}, 250);
|
||||
|
||||
var res = await req;
|
||||
var data = await res.json();
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
hideCopyProgress();
|
||||
if (!data.success) {
|
||||
alert('Failed: ' + (data.error || 'unknown'));
|
||||
renderButtons();
|
||||
@@ -800,6 +910,8 @@ async function loadMp4() {
|
||||
renderButtons();
|
||||
syncState();
|
||||
} catch (err) {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
hideCopyProgress();
|
||||
alert('Error: ' + err.message);
|
||||
renderButtons();
|
||||
} finally {
|
||||
@@ -807,6 +919,22 @@ async function loadMp4() {
|
||||
}
|
||||
}
|
||||
|
||||
function showCopyProgress(pct, done, total, name) {
|
||||
document.getElementById('upload-progress').style.display = 'block';
|
||||
document.getElementById('upload-progress-percent').style.display = 'block';
|
||||
document.getElementById('upload-progress-bar').style.width = pct + '%';
|
||||
document.getElementById('upload-progress-percent').textContent = pct + '%';
|
||||
document.getElementById('upload-status').textContent =
|
||||
'Copying ' + name + ' \u2014 ' + formatSize(done) + ' / ' + formatSize(total);
|
||||
}
|
||||
|
||||
function hideCopyProgress() {
|
||||
document.getElementById('upload-progress').style.display = 'none';
|
||||
document.getElementById('upload-progress-percent').style.display = 'none';
|
||||
document.getElementById('upload-progress-bar').style.width = '0%';
|
||||
document.getElementById('upload-progress-percent').textContent = '';
|
||||
}
|
||||
|
||||
async function startRecount() {
|
||||
if (!selectedFile || !loaded) return;
|
||||
var btnStart = document.getElementById('btn-start');
|
||||
|
||||
@@ -204,6 +204,36 @@
|
||||
.file-list {
|
||||
flex: 1; overflow-y: auto; max-height: 300px;
|
||||
}
|
||||
.output-panel {
|
||||
background: var(--surface);
|
||||
padding: 24px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.output-panel .fp-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.output-list {
|
||||
flex: 1; overflow-y: auto; max-height: 300px;
|
||||
}
|
||||
.folder-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; margin-bottom: 2px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--divider);
|
||||
cursor: pointer;
|
||||
font-size: 10px; letter-spacing: 2px; text-transform: uppercase;
|
||||
color: var(--white);
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.folder-header:hover { border-color: var(--gold); }
|
||||
.folder-header .folder-arrow {
|
||||
display: inline-block; color: var(--gold); font-size: 10px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.folder-header.open .folder-arrow { transform: rotate(90deg); }
|
||||
.folder-body { display: none; padding-left: 8px; }
|
||||
.folder-body.open { display: block; }
|
||||
.file-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 12px; margin-bottom: 2px;
|
||||
@@ -423,6 +453,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-panel">
|
||||
<div class="fp-header">
|
||||
<span class="section-title" style="margin-bottom:0;font-size:11px;">From Output Directory</span>
|
||||
<button class="btn btn-ghost" onclick="loadOutputFiles()" style="font-size:8px;padding:4px 8px;">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="file-list output-list" id="output-list">
|
||||
<div style="font-size:10px;color:var(--graphite);text-align:center;padding:20px;text-transform:uppercase;letter-spacing:1px;">
|
||||
No files found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">{{ site_name }} Recounting Upload Dashboard</div>
|
||||
</div>
|
||||
|
||||
@@ -439,10 +483,12 @@ let batchResultLoaded = false;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadRecountProgress();
|
||||
loadUploads();
|
||||
loadOutputFiles();
|
||||
syncState();
|
||||
|
||||
setInterval(loadRecountProgress, 500);
|
||||
setInterval(loadUploads, 30000);
|
||||
setInterval(loadOutputFiles, 30000);
|
||||
setInterval(syncState, 1000);
|
||||
|
||||
var drop = document.getElementById('upload-drop');
|
||||
@@ -618,6 +664,52 @@ async function loadUploads() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(fid) {
|
||||
var hdr = document.getElementById(fid + '-hdr');
|
||||
var body = document.getElementById(fid);
|
||||
if (hdr) hdr.classList.toggle('open');
|
||||
if (body) body.classList.toggle('open');
|
||||
}
|
||||
|
||||
async function loadOutputFiles() {
|
||||
var container = document.getElementById('output-list');
|
||||
try {
|
||||
var res = await fetch('/api/mp4-files');
|
||||
var folders = await res.json();
|
||||
if (!folders || folders.length === 0) {
|
||||
container.innerHTML = '<div style="font-size:10px;color:var(--graphite);text-align:center;padding:20px;text-transform:uppercase;letter-spacing:1px;">No MP4 files found</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = folders.map(function(g, gi) {
|
||||
var fid = 'ofolder-' + gi;
|
||||
return '<div class="folder-header" id="' + fid + '-hdr" onclick="toggleFolder(\'' + fid + '\')">' +
|
||||
'<span class="folder-arrow">▶</span>' +
|
||||
'<i class="fas fa-folder" style="color:var(--gold);"></i>' +
|
||||
'<span>' + g.folder + '</span>' +
|
||||
'<span style="font-size:8px;color:var(--graphite);">(' + g.files.length + ')</span>' +
|
||||
'</div>' +
|
||||
'<div class="folder-body" id="' + fid + '">' +
|
||||
g.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, '"') + '">' +
|
||||
'<span class="fi-icon">' + (str ? '◉' : '▶') + '</span>' +
|
||||
'<span class="fi-name">' + f.name + '</span>' +
|
||||
'<span class="fi-size">' + formatSize(f.size) + '</span>' +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('Failed to load output files:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
@@ -658,13 +750,29 @@ async function loadMp4() {
|
||||
var btnLoad = document.getElementById('btn-load');
|
||||
btnLoad.disabled = true;
|
||||
btnLoad.textContent = 'LOADING...';
|
||||
var pollTimer = null;
|
||||
try {
|
||||
var res = await fetch('/api/load-mp4', {
|
||||
var req = fetch('/api/load-mp4', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: selectedFile })
|
||||
});
|
||||
|
||||
pollTimer = setInterval(async function() {
|
||||
try {
|
||||
var pr = await fetch('/api/copy-progress');
|
||||
var p = await pr.json();
|
||||
if (p.active) {
|
||||
showCopyProgress(p.pct, p.done, p.total, p.name);
|
||||
}
|
||||
} catch (err) {}
|
||||
}, 250);
|
||||
|
||||
var res = await req;
|
||||
var data = await res.json();
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
hideCopyProgress();
|
||||
if (!data.success) {
|
||||
alert('Failed: ' + (data.error || 'unknown'));
|
||||
renderButtons();
|
||||
@@ -679,6 +787,8 @@ async function loadMp4() {
|
||||
renderButtons();
|
||||
syncState();
|
||||
} catch (err) {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
hideCopyProgress();
|
||||
alert('Error: ' + err.message);
|
||||
renderButtons();
|
||||
} finally {
|
||||
@@ -686,6 +796,22 @@ async function loadMp4() {
|
||||
}
|
||||
}
|
||||
|
||||
function showCopyProgress(pct, done, total, name) {
|
||||
document.getElementById('upload-progress').style.display = 'block';
|
||||
document.getElementById('upload-progress-percent').style.display = 'block';
|
||||
document.getElementById('upload-progress-bar').style.width = pct + '%';
|
||||
document.getElementById('upload-progress-percent').textContent = pct + '%';
|
||||
document.getElementById('upload-status').textContent =
|
||||
'Copying ' + name + ' \u2014 ' + formatSize(done) + ' / ' + formatSize(total);
|
||||
}
|
||||
|
||||
function hideCopyProgress() {
|
||||
document.getElementById('upload-progress').style.display = 'none';
|
||||
document.getElementById('upload-progress-percent').style.display = 'none';
|
||||
document.getElementById('upload-progress-bar').style.width = '0%';
|
||||
document.getElementById('upload-progress-percent').textContent = '';
|
||||
}
|
||||
|
||||
async function startRecount() {
|
||||
if (!selectedFile || !loaded) return;
|
||||
var btnStart = document.getElementById('btn-start');
|
||||
|
||||
Reference in new issue
Block a user