feat: async clip jobs, Siap-unduh state, cross-page ready toast
ci / smoke (push) Canceled after 0s

This commit is contained in:
andrew committed 2026-09-29 09:28:51 +07:00
1 parent 14f5ed4aac
commit 12f921f81c
6 files changed
+878 -66

No files matched your search

+177 -43
View File
@@ -8,6 +8,7 @@ Default port 5000 (replaces frigate-counter dashboard role).
import json
import os
import sqlite3
import threading
import time
from io import BytesIO
from datetime import datetime, timedelta
@@ -16,7 +17,7 @@ from uuid import uuid4
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from flask import Flask, render_template, jsonify, request, Response, send_file, after_this_request
from flask import Flask, render_template, jsonify, request, Response, send_file
from werkzeug.serving import WSGIRequestHandler
from dotenv import load_dotenv
@@ -1535,6 +1536,9 @@ def api_day_detail(date):
_CLIP_TMP_MAX_AGE = 3600 # seconds; stale clip_*.mp4 in $OUTPUT_DIR/tmp
_CLIP_JOBS = {} # key "YYYY-MM-DD/<batch>" -> clip job entry (dict)
_CLIP_JOBS_LOCK = threading.Lock()
def _unlink_quiet(path):
try:
@@ -1561,9 +1565,99 @@ def _clip_tmp_dir():
return tmp_dir
@app.route("/api/batch-clip/<date>/<batch_number>")
def _clip_sweep():
"""Stale tmp clips + matching _CLIP_JOBS entries (>1 h). Cheap, per request."""
tmp_dir = _clip_tmp_dir()
cutoff = time.time() - _CLIP_TMP_MAX_AGE
with _CLIP_JOBS_LOCK:
for key in list(_CLIP_JOBS):
job = _CLIP_JOBS[key]
path = job.get("path") or ""
if (job.get("created") or 0) < cutoff:
if path:
_unlink_quiet(path)
del _CLIP_JOBS[key]
elif job.get("status") == "ready" and not os.path.isfile(path):
del _CLIP_JOBS[key]
return tmp_dir
def _clip_download_name(batch_number, start_iso):
# b21-2026-09-26-14-25-09.mp4
try:
stamp = datetime.fromisoformat(str(start_iso)).strftime("%Y-%m-%d-%H-%M-%S")
except ValueError:
stamp = "0000-00-00-00-00-00"
return f"b{batch_number}-{stamp}.mp4"
def _clip_job_worker(key, date, batch_number, start_iso, end_iso, motion_url,
camera_id, out_path, pad, align):
"""Background thread: build the clip, publish status into _CLIP_JOBS."""
def _finish(status, error=""):
with _CLIP_JOBS_LOCK:
job = _CLIP_JOBS.get(key)
if job is not None:
job["status"] = status
job["error"] = error
print(f"[CLIP] job {key}: {status}", flush=True)
# Lazy: src.clip (ffmpeg wrapper) not needed to import the dashboard, and
# keeps `import counter_dashboard` working when the module is absent.
try:
from src.clip import ClipError, clip_batch
except Exception as e:
_unlink_quiet(out_path)
_finish("error", f"Modul klip belum tersedia: {e}")
return
try:
clip_batch(
motion_url, camera_id, str(start_iso), str(end_iso), out_path,
pad_seconds=pad, align_osd=align,
)
except ClipError as ce:
_unlink_quiet(out_path)
_finish("error", str(ce))
return
except Exception as exc:
_unlink_quiet(out_path)
_finish("error", f"Gagal membuat klip: {exc}")
return
if os.path.isfile(out_path):
_finish("ready")
else:
_finish("error", "file tidak dihasilkan")
@app.route("/api/batch-clip/pending")
def api_batch_clip_pending():
"""Office-only: clip jobs currently tracked (running/ready/error)."""
if not is_office_request():
return jsonify({"success": False, "error": "Hanya bisa diakses dari port kantor"}), 403
_clip_sweep()
with _CLIP_JOBS_LOCK:
jobs = [
{
"date": j.get("date", ""),
"batch_number": j.get("batch_number"),
"start_time": j.get("start_time", ""),
"status": j.get("status", ""),
"error": j.get("error", ""),
}
for j in _CLIP_JOBS.values()
]
jobs.sort(key=lambda j: str(j["start_time"]), reverse=True)
return jsonify({"success": True, "jobs": jobs}), 200
@app.route("/api/batch-clip/<date>/<batch_number>", methods=["GET", "POST"])
def api_batch_clip(date, batch_number):
"""Office-only: clip motionEye recording to the batch window, download as mp4."""
"""Office-only: queue a background clip job for the batch window (returns immediately).
POST is the contract method; GET kept so legacy history-page fetches get the
job JSON instead of 405 (frontend now polls /status + /file).
"""
if not is_office_request():
return jsonify({"success": False, "error": "Hanya bisa diakses dari port kantor"}), 403
@@ -1612,52 +1706,92 @@ def api_batch_clip(date, batch_number):
return jsonify({"success": False, "error": "Batch belum selesai (start/end time kosong)"}), 400
try:
start_dt = datetime.fromisoformat(str(start_iso))
datetime.fromisoformat(str(start_iso))
except ValueError:
return jsonify({"success": False, "error": f"Waktu mulai batch tidak valid: {start_iso}"}), 500
# b21-2026-09-26-14-25-09.mp4
filename = f"b{batch_number}-{start_dt.strftime('%Y-%m-%d-%H-%M-%S')}.mp4"
tmp_dir = _clip_tmp_dir()
tmp_path = os.path.join(tmp_dir, f"clip_{uuid4().hex}.mp4")
key = f"{date}/{batch_number}"
tmp_dir = _clip_sweep()
now = time.time()
with _CLIP_JOBS_LOCK:
job = _CLIP_JOBS.get(key)
if job is not None:
if job.get("status") == "running":
return jsonify({"success": True, "status": "running", "job": key}), 200
if job.get("status") == "ready" and os.path.isfile(job.get("path") or ""):
return jsonify({"success": True, "status": "ready", "job": key}), 200
# error (or stale ready) → retry: drop old temp, fresh job below
_unlink_quiet(job.get("path") or "")
del _CLIP_JOBS[key]
tmp_path = os.path.join(tmp_dir, f"clip_{uuid4().hex}.mp4")
_CLIP_JOBS[key] = {
"status": "running",
"date": date,
"batch_number": batch_number,
"start_time": str(start_iso),
"error": "",
"path": tmp_path,
"created": now,
"started": now,
}
threading.Thread(
target=_clip_job_worker,
args=(key, date, batch_number, str(start_iso), str(end_iso),
motion_url, camera_id, tmp_path, pad_seconds, align_osd),
daemon=True,
).start()
return jsonify({"success": True, "status": "running", "job": key}), 200
# Lazy: src.clip (ffmpeg wrapper) not needed to import the dashboard, and
# keeps `import counter_dashboard` working when the module is absent.
try:
from src.clip import ClipError, clip_batch
except Exception as e:
return jsonify({"success": False, "error": f"Modul klip belum tersedia: {e}"}), 503
try:
clip_batch(
motion_url, camera_id, str(start_iso), str(end_iso), tmp_path,
pad_seconds=pad_seconds, align_osd=align_osd,
@app.route("/api/batch-clip/<date>/<batch_number>/status")
def api_batch_clip_status(date, batch_number):
"""Office-only: poll one clip job (elapsed = seconds since job start)."""
if not is_office_request():
return jsonify({"success": False, "error": "Hanya bisa diakses dari port kantor"}), 403
_clip_sweep()
key = f"{date}/{batch_number}"
with _CLIP_JOBS_LOCK:
job = dict(_CLIP_JOBS.get(key) or {})
if not job:
return jsonify({"success": False, "error": "not found"}), 404
started = job.get("started") or job.get("created") or time.time()
elapsed = int(max(0.0, time.time() - started))
return jsonify(
{
"success": True,
"status": job.get("status", ""),
"job": key,
"error": job.get("error", ""),
"elapsed": elapsed,
}
), 200
@app.route("/api/batch-clip/<date>/<batch_number>/file")
def api_batch_clip_file(date, batch_number):
"""Office-only: download the finished clip (temp kept for re-download)."""
if not is_office_request():
return jsonify({"success": False, "error": "Hanya bisa diakses dari port kantor"}), 403
_clip_sweep()
key = f"{date}/{batch_number}"
with _CLIP_JOBS_LOCK:
job = dict(_CLIP_JOBS.get(key) or {})
if not job:
return jsonify({"success": False, "error": "not found"}), 404
status = job.get("status")
path = job.get("path") or ""
if status == "ready" and os.path.isfile(path):
return send_file(
path,
as_attachment=True,
download_name=_clip_download_name(job.get("batch_number"), job.get("start_time")),
mimetype="video/mp4",
)
except ClipError as ce:
_unlink_quiet(tmp_path)
msg = str(ce)
low = msg.lower()
no_recording = ("tidak ada rekaman" in low) or ("no recording" in low) or ("not found" in low)
# 404 = no recording in window, 502 = other motionEye/ffmpeg failure.
return jsonify({"success": False, "error": msg}), (404 if no_recording else 502)
except Exception as e:
_unlink_quiet(tmp_path)
return jsonify({"success": False, "error": str(e)}), 500
if not os.path.isfile(tmp_path):
return jsonify({"success": False, "error": "Klip gagal dibuat"}), 502
resp = send_file(tmp_path, as_attachment=True, download_name=filename, mimetype="video/mp4")
# send_file already holds the file open → delete once headers are committed
# (survives a test client that never closes the response) and again when
# the response closes (server side). _unlink_quiet is idempotent.
if not app.config.get("USE_X_SENDFILE"):
@after_this_request
def _cleanup_clip(response):
_unlink_quiet(tmp_path)
return response
resp.call_on_close(lambda: _unlink_quiet(tmp_path))
return resp
if status == "running":
return jsonify({"success": False, "error": "masih diproses"}), 409
if status == "error":
return jsonify({"success": False, "error": job.get("error") or "Gagal membuat klip"}), 404
return jsonify({"success": False, "error": "not found"}), 404
@app.route("/api/recent-batches")