feat: batch clip download from motionEye (trim+concat, history Klip button)

This commit is contained in:
andrew committed 2026-09-28 15:39:33 +07:00
1 parent d3357fccef
commit 110d557dbf
8 files changed
+694 -6

No files matched your search

+111 -1
View File
@@ -11,11 +11,12 @@ import sqlite3
import time
from io import BytesIO
from datetime import datetime, timedelta
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
from flask import Flask, render_template, jsonify, request, Response, send_file, after_this_request
from werkzeug.serving import WSGIRequestHandler
from dotenv import load_dotenv
@@ -1532,6 +1533,115 @@ def api_day_detail(date):
return jsonify({"success": False, "error": str(e)}), 500
_CLIP_TMP_MAX_AGE = 3600 # seconds; stale clip_*.mp4 in $OUTPUT_DIR/tmp
def _unlink_quiet(path):
try:
os.unlink(path)
except OSError:
pass
def _clip_tmp_dir():
tmp_dir = os.path.join(os.path.dirname(DB_PATH), "tmp")
try:
os.makedirs(tmp_dir, exist_ok=True)
except OSError:
pass
try:
cutoff = time.time() - _CLIP_TMP_MAX_AGE
for name in os.listdir(tmp_dir):
if name.startswith("clip_") and name.endswith(".mp4"):
p = os.path.join(tmp_dir, name)
if os.path.getmtime(p) < cutoff:
_unlink_quiet(p)
except Exception:
pass
return tmp_dir
@app.route("/api/batch-clip/<date>/<batch_number>")
def api_batch_clip(date, batch_number):
"""Office-only: clip motionEye recording to the batch window, download as mp4."""
if not is_office_request():
return jsonify({"success": False, "error": "Hanya bisa diakses dari port kantor"}), 403
motion_url = (os.getenv("MOTIONEYE_URL") or "").strip()
if not motion_url:
return jsonify({"success": False, "error": "motionEye belum dikonfigurasi (MOTIONEYE_URL)"}), 404
try:
camera_id = int(os.getenv("MOTIONEYE_CAMERA_ID") or "2")
except ValueError:
camera_id = 2
if not str(batch_number).isdigit():
return jsonify({"success": False, "error": f"batch_number tidak valid: {batch_number}"}), 400
batch_number = int(batch_number)
try:
conn = get_db()
cur = conn.cursor()
cur.execute(
"SELECT start_time, end_time FROM batches WHERE counting_date = ? AND batch_number = ?",
(date, batch_number),
)
row = cur.fetchone()
conn.close()
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
if row is None:
return jsonify({"success": False, "error": "Batch tidak ditemukan"}), 404
start_iso, end_iso = row["start_time"], row["end_time"]
if not start_iso or not end_iso:
return jsonify({"success": False, "error": "Batch belum selesai (start/end time kosong)"}), 400
try:
start_dt = 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")
# 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)
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
@app.route("/api/recent-batches")
def api_recent_batches():
try: