feat: batch clip download from motionEye (trim+concat, history Klip button)
This commit is contained in:
1 parent
d3357fccef
commit
110d557dbf
8 files changed
+694
-6
No files matched your search
+111
-1
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user