This commit is contained in:
1 parent
14f5ed4aac
commit
12f921f81c
6 files changed
+878
-66
No files matched your search
+177
-43
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user