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
@@ -28,6 +28,10 @@ SITE_NAME=LIVE
|
||||
# --- Stream (canonical home; read from env, NOT config.yaml) ---
|
||||
RTSP_URL=rtsp://user:pass@192.168.192.209:8554/camera_stream_640
|
||||
|
||||
# --- Batch clip download (motionEye; dashboard /api/batch-clip only) ---
|
||||
MOTIONEYE_URL=http://192.168.192.253:8765 # motionEye base URL for batch clip download; empty disables
|
||||
MOTIONEYE_CAMERA_ID=2 # motionEye camera id (1 = default camera)
|
||||
|
||||
# --- Deprecated: model pipeline mode now lives in config.yaml models.active_mode.
|
||||
# Dashboard switches persist there (atomic write, comments preserved) and apply
|
||||
# on next `karung-counter` restart. Only set this to temporarily override.
|
||||
|
||||
@@ -29,6 +29,9 @@ version tags are cut in this repo — POC stage, `main` is the release line).
|
||||
`None (Manual)` + require-plate controls; history/export plate, DO,
|
||||
expected, net columns.
|
||||
- `ERD.md` (Mermaid ERD, repo root; superseded `docs/do-erd.md`); tests `tests/test_do_batch.py`.
|
||||
- History `Klip` button → office-only `GET /api/batch-clip/<date>/<batch_number>`
|
||||
trims+concatenates motionEye recordings to the batch window (`src/clip.py`),
|
||||
download `b<n>-<date>-<HH-MM-SS>.mp4`.
|
||||
|
||||
### Changed
|
||||
- OCR engine default **`rapid`** (was `tesseract`) across `config.yaml`,
|
||||
|
||||
+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:
|
||||
|
||||
@@ -78,6 +78,8 @@ when set.
|
||||
| `OFFICE_PORT` | `5721` | Second dashboard port |
|
||||
| `FLASK_DEBUG` | `false` | Flask debug |
|
||||
| `RTSP_URL` | — | Camera stream URL (env-only, never in YAML) |
|
||||
| `MOTIONEYE_URL` | `""` | motionEye base URL for batch clip download (empty disables the endpoint) |
|
||||
| `MOTIONEYE_CAMERA_ID` | `2` | motionEye camera id used for batch clip download |
|
||||
| `MODEL_PATH` | — (deprecated) | Single-file v4 override, folded into `models.paths` |
|
||||
| `MODEL_MODE` | — (deprecated) | One-run override of `models.active_mode` (warned) |
|
||||
| `BATCH_MERGE_THRESHOLD_SECONDS` | `300` | Merge window for adjacent batches |
|
||||
|
||||
+27
-1
@@ -87,7 +87,33 @@ Pages: `/` + `/monitoring`, `/operator` (manual start/stop; DO panel in
|
||||
`/api/do/{upload,photo/<id>,staged,settings,retention}` +
|
||||
`PUT/DELETE /api/do/<id>`, `/api/summary`, `/api/daily-data`,
|
||||
`/api/day-detail/<date>`, `/api/recent-batches`, `/api/available-dates`,
|
||||
`/api/export-daily-csv`, `/api/export-day-csv/<date>` (Excel via openpyxl).
|
||||
`/api/export-daily-csv`, `/api/export-day-csv/<date>` (Excel via openpyxl),
|
||||
`/api/batch-clip/<date>/<batch_number>` (office-only, see below).
|
||||
|
||||
Port split: mode / model_mode / require_plate / require_do / ocr_engine POSTs →
|
||||
**office 5721 only** (403 on 5000). Smartphones open `http://<host>:5000/operator` for camera capture.
|
||||
|
||||
## Batch clip download (motionEye)
|
||||
|
||||
History page `Klip` button → `GET /api/batch-clip/<date>/<batch_number>`
|
||||
(**office port 5721 only**, 403 on 5000) trims + concatenates motionEye
|
||||
recordings for the batch window (`clip_batch` in `src/clip.py`, ffmpeg
|
||||
under the hood) and returns the file as an attachment.
|
||||
|
||||
- URL keys (`.env`): `MOTIONEYE_URL` (base URL; empty → 404
|
||||
`motionEye belum dikonfigurasi (MOTIONEYE_URL)`), `MOTIONEYE_CAMERA_ID`
|
||||
(default `2`).
|
||||
- motionEye HTTP API used: `GET /movie/<id>/list/` (recording segments in the
|
||||
window) and `GET /movie/<id>/download<path>` (segment bytes).
|
||||
- Requires `ffmpeg` / `ffprobe` on PATH — both already installed on the Jetson.
|
||||
- **Recording retention: motionEye `preserve_movies: 3` → only the last 3 days
|
||||
of recordings exist.** Older batches return 404 (`ClipError`, no recording in
|
||||
window).
|
||||
- Errors: `ClipError` "no recording" → **404**, other `ClipError`
|
||||
(motionEye/ffmpeg failure) → **502**, unexpected exception → **500**;
|
||||
`src.clip` missing → **503**. Temp file `$(dirname DB)/tmp/clip_<uuid>.mp4`
|
||||
is always unlinked (stale `clip_*.mp4` older than 1 h swept on each request).
|
||||
- Output filename built from the batch **start_time**:
|
||||
`b{batch}-{YYYY-MM-DD}-{HH-MM-SS}.mp4`
|
||||
(e.g. batch 21 starting `2026-09-26T14:25:09.123456` →
|
||||
`b21-2026-09-26-14-25-09.mp4`).
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
"""motionEye batch-clip download: list → cover → ffmpeg trim+concat."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
_PATH_RE = re.compile(r"(\d{4}-\d{2}-\d{2})/(\d{2})-(\d{2})-(\d{2})\.mp4$")
|
||||
_CANDIDATE_BACK = timedelta(minutes=90)
|
||||
_OVERLAP_MIN = 0.5
|
||||
_NO_CLIP_MSG = (
|
||||
"Tidak ada rekaman motionEye untuk rentang waktu batch ini "
|
||||
"(retensi rekaman 3 hari)."
|
||||
)
|
||||
|
||||
|
||||
class ClipError(Exception):
|
||||
"""User-facing failure (Indonesian messages)."""
|
||||
|
||||
|
||||
def _path_start(path):
|
||||
"""Parse 'YYYY-MM-DD/HH-MM-SS.mp4' (leading '/' ok) → naive datetime, else None."""
|
||||
if not path or not isinstance(path, str):
|
||||
return None
|
||||
match = _PATH_RE.match(path.lstrip("/"))
|
||||
if not match:
|
||||
return None
|
||||
date_s, hh, mm, ss = match.groups()
|
||||
try:
|
||||
return datetime.strptime(f"{date_s} {hh}:{mm}:{ss}", "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _download_url(motion_url: str, camera_id, path: str) -> str:
|
||||
return "{}/movie/{}/download{}".format(
|
||||
motion_url.rstrip("/"), camera_id, urllib.parse.quote(path, safe="/")
|
||||
)
|
||||
|
||||
|
||||
def list_movies(motion_url: str, camera_id: int) -> list:
|
||||
"""GET {motion_url}/movie/{camera_id}/list/, return [{'path', 'start'}] sorted."""
|
||||
url = "{}/movie/{}/list/".format(motion_url.rstrip("/"), camera_id)
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=30) as resp:
|
||||
body = resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise ClipError(
|
||||
"Gagal mengambil daftar rekaman motionEye (HTTP {}).".format(exc.code)
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ClipError("Gagal mengambil daftar rekaman motionEye: {}".format(exc))
|
||||
try:
|
||||
items = json.loads(body.decode("utf-8", "replace"))["mediaList"]
|
||||
except Exception:
|
||||
raise ClipError("Respons daftar rekaman motionEye tidak valid (bukan JSON).")
|
||||
movies = []
|
||||
for item in items if isinstance(items, list) else []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
path = item.get("path")
|
||||
start = _path_start(path)
|
||||
if start is None:
|
||||
continue
|
||||
movies.append({"path": path, "start": start})
|
||||
movies.sort(key=lambda movie: movie["start"])
|
||||
return movies
|
||||
|
||||
|
||||
def find_clips(movies: list, start: datetime, end: datetime, probe) -> list:
|
||||
"""Select clips overlapping [start, end]; probe(path) -> duration seconds."""
|
||||
earliest = start - _CANDIDATE_BACK
|
||||
selected = []
|
||||
for movie in movies:
|
||||
clip_start = movie.get("start")
|
||||
if clip_start is None or not (earliest <= clip_start <= end):
|
||||
continue
|
||||
try:
|
||||
duration = float(probe(movie["path"]))
|
||||
except Exception:
|
||||
duration = 0.0
|
||||
if not duration > 0:
|
||||
continue
|
||||
off0 = max(0.0, (start - clip_start).total_seconds())
|
||||
off1 = min(duration, (end - clip_start).total_seconds())
|
||||
if not (off1 - off0 > _OVERLAP_MIN):
|
||||
continue
|
||||
selected.append(
|
||||
{
|
||||
"path": movie["path"],
|
||||
"start": clip_start,
|
||||
"duration": duration,
|
||||
"off0": off0,
|
||||
"off1": off1,
|
||||
}
|
||||
)
|
||||
selected.sort(key=lambda clip: clip["start"])
|
||||
return selected
|
||||
|
||||
|
||||
def probe_duration(url: str, timeout: int = 30) -> float:
|
||||
"""ffprobe duration in seconds; any failure → 0.0 (never raises)."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "csv=p=0",
|
||||
url,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return 0.0
|
||||
lines = proc.stdout.decode("utf-8", "replace").strip().splitlines()
|
||||
return float(lines[0]) if lines else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _fmt(seconds: float) -> str:
|
||||
return "{:.3f}".format(seconds)
|
||||
|
||||
|
||||
def _piece_argv(clip_url: str, off0: float, off1: float, piece: str) -> list:
|
||||
return [
|
||||
"ffmpeg", "-y",
|
||||
"-ss", _fmt(off0),
|
||||
"-to", _fmt(off1),
|
||||
"-i", clip_url,
|
||||
"-c", "copy",
|
||||
piece,
|
||||
]
|
||||
|
||||
|
||||
def _concat_argv(list_file: str, out_path: str) -> list:
|
||||
return [
|
||||
"ffmpeg", "-y",
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-i", list_file,
|
||||
"-c", "copy",
|
||||
out_path,
|
||||
]
|
||||
|
||||
|
||||
def _run_ffmpeg(args: list, timeout: int):
|
||||
try:
|
||||
proc = subprocess.run(args, capture_output=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ClipError("ffmpeg timeout setelah {} detik.".format(timeout))
|
||||
except OSError as exc:
|
||||
raise ClipError("ffmpeg gagal dijalankan: {}".format(exc))
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or b"").decode("utf-8", "replace")
|
||||
raise ClipError("ffmpeg gagal: ...{}".format(err[-300:]))
|
||||
return proc
|
||||
|
||||
|
||||
def clip_batch(motion_url: str, camera_id: int, start_iso: str, end_iso: str,
|
||||
out_path: str, timeout: int = 180) -> None:
|
||||
"""List → cover → trim pieces → concat into out_path. Raises ClipError only."""
|
||||
try:
|
||||
start = datetime.fromisoformat(start_iso)
|
||||
end = datetime.fromisoformat(end_iso)
|
||||
out_path = os.path.abspath(out_path)
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
tmpdir = None
|
||||
try:
|
||||
movies = list_movies(motion_url, camera_id)
|
||||
|
||||
def probe(path):
|
||||
return probe_duration(_download_url(motion_url, camera_id, path))
|
||||
|
||||
clips = find_clips(movies, start, end, probe)
|
||||
if not clips:
|
||||
raise ClipError(_NO_CLIP_MSG)
|
||||
tmpdir = tempfile.mkdtemp(dir=os.path.dirname(out_path))
|
||||
pieces = []
|
||||
for index, clip in enumerate(clips):
|
||||
piece = os.path.join(tmpdir, "piece_{:03d}.mp4".format(index))
|
||||
_run_ffmpeg(
|
||||
_piece_argv(
|
||||
_download_url(motion_url, camera_id, clip["path"]),
|
||||
clip["off0"],
|
||||
clip["off1"],
|
||||
piece,
|
||||
),
|
||||
timeout,
|
||||
)
|
||||
pieces.append(piece)
|
||||
if len(pieces) == 1:
|
||||
shutil.move(pieces[0], out_path)
|
||||
else:
|
||||
list_file = os.path.join(tmpdir, "concat.txt")
|
||||
with open(list_file, "w") as handle:
|
||||
for piece in pieces:
|
||||
handle.write("file '{}'\n".format(piece))
|
||||
_run_ffmpeg(_concat_argv(list_file, out_path), timeout)
|
||||
except ClipError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ClipError("Gagal membuat klip batch: {}".format(exc)) from exc
|
||||
finally:
|
||||
if tmpdir:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
except ClipError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ClipError("Gagal membuat klip batch: {}".format(exc)) from exc
|
||||
+54
-4
@@ -269,11 +269,12 @@
|
||||
<th>Start Time</th>
|
||||
<th>End Time</th>
|
||||
<th>Duration (Min)</th>
|
||||
<th>Klip</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="batchesTableBody">
|
||||
<tr>
|
||||
<td colspan="9" class="no-data">
|
||||
<td colspan="10" class="no-data">
|
||||
<i class="fa-solid fa-arrow-left"></i>
|
||||
Silakan pilih tanggal dari daftar sebelah kiri untuk memuat detail batch.
|
||||
</td>
|
||||
@@ -357,7 +358,7 @@
|
||||
const tableBody = document.getElementById('batchesTableBody');
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="9" class="no-data">
|
||||
<td colspan="10" class="no-data">
|
||||
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
Memuat data batch untuk tanggal ${date}...
|
||||
</td>
|
||||
@@ -383,7 +384,7 @@
|
||||
if (!data.batches || data.batches.length === 0) {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="9" class="no-data">
|
||||
<td colspan="10" class="no-data">
|
||||
<i class="fa-solid fa-inbox"></i>
|
||||
Tidak ada batch tercatat pada tanggal ${date}.
|
||||
</td>
|
||||
@@ -411,6 +412,15 @@
|
||||
<td>${startStr}</td>
|
||||
<td>${endStr}</td>
|
||||
<td>${b.duration_minutes || 0} min</td>
|
||||
<td style="text-align: center;">
|
||||
<button type="button" class="btn btn-secondary"
|
||||
style="padding: 5px 10px; font-size: 12px; gap: 6px;"
|
||||
data-date="${escapeHtmlHist(selectedDate)}"
|
||||
data-batch="${b.batch_number}"
|
||||
onclick="downloadBatchClip(this)">
|
||||
<i class="fa-solid fa-film"></i> Klip
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
tableBody.appendChild(tr);
|
||||
});
|
||||
@@ -419,7 +429,7 @@
|
||||
console.error('Failed to load date details:', err);
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="9" class="no-data" style="color: var(--accent-red);">
|
||||
<td colspan="10" class="no-data" style="color: var(--accent-red);">
|
||||
<i class="fa-solid fa-circle-exclamation"></i>
|
||||
Gagal memuat detail data: ${err.message}
|
||||
</td>
|
||||
@@ -434,6 +444,46 @@
|
||||
})[c]);
|
||||
}
|
||||
|
||||
// Klip: fetch office-only /api/batch-clip/<date>/<batch> (may take ~30s),
|
||||
// then save the blob under the filename from Content-Disposition.
|
||||
async function downloadBatchClip(btn) {
|
||||
if (btn.disabled) return;
|
||||
const date = btn.dataset.date;
|
||||
const batch = btn.dataset.batch;
|
||||
const original = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Memuat...';
|
||||
try {
|
||||
const res = await fetch(`/api/batch-clip/${encodeURIComponent(date)}/${encodeURIComponent(batch)}`);
|
||||
if (!res.ok) {
|
||||
let msg = `Gagal mengunduh klip (HTTP ${res.status})`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data && data.error) msg = data.error;
|
||||
} catch (e) { /* body bukan JSON */ }
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="?([^";]+)"?/);
|
||||
const filename = m ? m[1] : `b${batch}-${date}.mp4`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
} catch (err) {
|
||||
alert('Gagal mengunduh klip: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = original;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadDates);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Tests for src/clip.py — stdlib only, no network, no ffmpeg."""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
import src.clip as clip
|
||||
from src.clip import ClipError, find_clips, _path_start
|
||||
|
||||
|
||||
def dt(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value)
|
||||
|
||||
|
||||
def fake_probe(durations):
|
||||
"""probe(path) -> duration; records calls on probe.calls."""
|
||||
calls = []
|
||||
|
||||
def probe(path):
|
||||
calls.append(path)
|
||||
return durations.get(path, 0.0)
|
||||
|
||||
probe.calls = calls
|
||||
return probe
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- path parsing
|
||||
|
||||
def test_path_start_valid_path():
|
||||
assert _path_start("2026-09-26/09-51-40.mp4") == datetime(2026, 9, 26, 9, 51, 40)
|
||||
assert _path_start("/2026-09-26/09-51-40.mp4") == datetime(2026, 9, 26, 9, 51, 40)
|
||||
|
||||
|
||||
def test_path_start_skips_unparseable_paths():
|
||||
assert _path_start("2026-09-26/09-51-40.avi") is None
|
||||
assert _path_start("09-51-40.mp4") is None
|
||||
assert _path_start("2026-9-26/09-51-40.mp4") is None
|
||||
assert _path_start("") is None
|
||||
assert _path_start(None) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ find_clips
|
||||
|
||||
def test_find_clips_only_overlapping_clip():
|
||||
movies = [
|
||||
{"path": "2026-09-26/09-00-00.mp4", "start": dt("2026-09-26 09:00:00")},
|
||||
{"path": "2026-09-26/09-20-00.mp4", "start": dt("2026-09-26 09:20:00")},
|
||||
]
|
||||
probe = fake_probe({
|
||||
"2026-09-26/09-00-00.mp4": 300.0,
|
||||
"2026-09-26/09-20-00.mp4": 60.0,
|
||||
})
|
||||
# B(09:20, 60s): window starts 10s into B and ends 50s into B.
|
||||
got = find_clips(movies, dt("2026-09-26 09:20:10"), dt("2026-09-26 09:20:50"), probe)
|
||||
assert [c["path"] for c in got] == ["2026-09-26/09-20-00.mp4"]
|
||||
assert got[0]["off0"] == 10.0
|
||||
assert got[0]["off1"] == 50.0
|
||||
assert got[0]["duration"] == 60.0
|
||||
|
||||
|
||||
def test_find_clips_window_touching_clip_end_excluded():
|
||||
# B(09:20, 60s) ends exactly 09:21; window 09:21→09:25 overlaps by 0s.
|
||||
movies = [
|
||||
{"path": "2026-09-26/09-00-00.mp4", "start": dt("2026-09-26 09:00:00")},
|
||||
{"path": "2026-09-26/09-20-00.mp4", "start": dt("2026-09-26 09:20:00")},
|
||||
]
|
||||
probe = fake_probe({
|
||||
"2026-09-26/09-00-00.mp4": 300.0,
|
||||
"2026-09-26/09-20-00.mp4": 60.0,
|
||||
})
|
||||
got = find_clips(movies, dt("2026-09-26 09:21:00"), dt("2026-09-26 09:25:00"), probe)
|
||||
assert got == []
|
||||
|
||||
|
||||
def test_find_clips_window_inside_one_clip():
|
||||
movies = [{"path": "2026-09-26/10-00-00.mp4", "start": dt("2026-09-26 10:00:00")}]
|
||||
probe = fake_probe({"2026-09-26/10-00-00.mp4": 600.0})
|
||||
got = find_clips(movies, dt("2026-09-26 10:02:00"), dt("2026-09-26 10:04:00"), probe)
|
||||
assert len(got) == 1
|
||||
assert got[0]["off0"] == 120.0
|
||||
assert got[0]["off1"] == 240.0
|
||||
|
||||
|
||||
def test_find_clips_window_spanning_two_clips_in_order():
|
||||
movies = [
|
||||
{"path": "2026-09-26/09-04-00.mp4", "start": dt("2026-09-26 09:04:00")},
|
||||
{"path": "2026-09-26/09-00-00.mp4", "start": dt("2026-09-26 09:00:00")},
|
||||
]
|
||||
probe = fake_probe({
|
||||
"2026-09-26/09-00-00.mp4": 300.0,
|
||||
"2026-09-26/09-04-00.mp4": 600.0,
|
||||
})
|
||||
got = find_clips(movies, dt("2026-09-26 09:03:00"), dt("2026-09-26 09:05:30"), probe)
|
||||
assert [c["path"] for c in got] == [
|
||||
"2026-09-26/09-00-00.mp4",
|
||||
"2026-09-26/09-04-00.mp4",
|
||||
]
|
||||
assert (got[0]["off0"], got[0]["off1"]) == (180.0, 300.0)
|
||||
assert (got[1]["off0"], got[1]["off1"]) == (0.0, 90.0)
|
||||
|
||||
|
||||
def test_find_clips_no_overlap_empty():
|
||||
movies = [{"path": "2026-09-26/10-35-00.mp4", "start": dt("2026-09-26 10:35:00")}]
|
||||
probe = fake_probe({"2026-09-26/10-35-00.mp4": 60.0})
|
||||
got = find_clips(movies, dt("2026-09-26 12:00:00"), dt("2026-09-26 12:01:00"), probe)
|
||||
assert got == []
|
||||
|
||||
|
||||
def test_find_clips_probe_zero_duration_skipped():
|
||||
movies = [{"path": "2026-09-26/11-59-00.mp4", "start": dt("2026-09-26 11:59:00")}]
|
||||
probe = fake_probe({})
|
||||
assert probe("2026-09-26/11-59-00.mp4") == 0.0
|
||||
got = find_clips(movies, dt("2026-09-26 12:00:00"), dt("2026-09-26 12:01:00"),
|
||||
fake_probe({"2026-09-26/11-59-00.mp4": 0.0}))
|
||||
assert got == []
|
||||
|
||||
|
||||
def test_find_clips_probe_error_treated_as_zero():
|
||||
def broken_probe(path):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
movies = [{"path": "2026-09-26/11-59-00.mp4", "start": dt("2026-09-26 11:59:00")}]
|
||||
got = find_clips(movies, dt("2026-09-26 12:00:00"), dt("2026-09-26 12:01:00"),
|
||||
broken_probe)
|
||||
assert got == []
|
||||
|
||||
|
||||
def test_find_clips_midnight_crossing_window():
|
||||
movies = [
|
||||
{"path": "/2026-09-26/23-58-00.mp4", "start": dt("2026-09-26 23:58:00")},
|
||||
{"path": "/2026-09-27/00-00-30.mp4", "start": dt("2026-09-27 00:00:30")},
|
||||
]
|
||||
probe = fake_probe({
|
||||
"/2026-09-26/23-58-00.mp4": 300.0,
|
||||
"/2026-09-27/00-00-30.mp4": 300.0,
|
||||
})
|
||||
got = find_clips(movies, dt("2026-09-26 23:59:00"), dt("2026-09-27 00:01:00"), probe)
|
||||
assert [c["path"] for c in got] == [
|
||||
"/2026-09-26/23-58-00.mp4",
|
||||
"/2026-09-27/00-00-30.mp4",
|
||||
]
|
||||
assert (got[1]["off0"], got[1]["off1"]) == (0.0, 30.0)
|
||||
|
||||
|
||||
def test_find_clips_old_clip_not_probed():
|
||||
start, end = dt("2026-09-26 12:00:00"), dt("2026-09-26 12:10:00")
|
||||
old = {"path": "old.mp4", "start": start - clip._CANDIDATE_BACK - clip.timedelta(seconds=1)}
|
||||
edge = {"path": "edge.mp4", "start": start - clip._CANDIDATE_BACK}
|
||||
inside = {"path": "inside.mp4", "start": dt("2026-09-26 11:59:00")}
|
||||
probe = fake_probe({
|
||||
"old.mp4": 3600.0,
|
||||
"edge.mp4": 3600.0,
|
||||
"inside.mp4": 60.0,
|
||||
})
|
||||
find_clips([old, edge, inside], start, end, probe)
|
||||
assert "old.mp4" not in probe.calls
|
||||
assert "edge.mp4" in probe.calls
|
||||
assert "inside.mp4" in probe.calls
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- clip_batch
|
||||
|
||||
BASE = "http://192.168.192.253:8765"
|
||||
CLIP_A = "/2026-09-26/09-00-00.mp4"
|
||||
CLIP_B = "/2026-09-26/09-04-00.mp4"
|
||||
START_ISO = "2026-09-26T09:03:00"
|
||||
END_ISO = "2026-09-26T09:05:30"
|
||||
|
||||
|
||||
def test_clip_batch_no_recording_raises(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(clip, "list_movies", lambda *a, **k: [])
|
||||
monkeypatch.setattr(clip, "find_clips", lambda *a, **k: [])
|
||||
with pytest.raises(ClipError, match="Tidak ada rekaman motionEye"):
|
||||
clip.clip_batch(BASE, 1, START_ISO, END_ISO, str(tmp_path / "out.mp4"))
|
||||
|
||||
|
||||
def fake_run_factory(calls, captured):
|
||||
def fake_run(args, timeout):
|
||||
args = list(args)
|
||||
calls.append((args, timeout))
|
||||
if "concat" in args:
|
||||
list_file = args[args.index("-i") + 1]
|
||||
with open(list_file) as handle:
|
||||
captured["concat_list"] = handle.read()
|
||||
target = args[-1]
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
with open(target, "wb") as handle:
|
||||
handle.write(b"piece-bytes")
|
||||
return fake_run
|
||||
|
||||
|
||||
def patch_batch_env(monkeypatch, movies, duration=600.0):
|
||||
monkeypatch.setattr(clip, "list_movies", lambda *a, **k: list(movies))
|
||||
monkeypatch.setattr(clip, "probe_duration", lambda url, timeout=30: duration)
|
||||
|
||||
|
||||
def test_clip_batch_single_piece_argv(monkeypatch, tmp_path):
|
||||
movies = [{"path": CLIP_A, "start": dt("2026-09-26 09:00:00")}]
|
||||
patch_batch_env(monkeypatch, movies, duration=600.0)
|
||||
calls, captured = [], {}
|
||||
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
||||
out = tmp_path / "sub" / "batch.mp4"
|
||||
clip.clip_batch(BASE, 1, "2026-09-26T09:00:10", "2026-09-26T09:00:50", str(out))
|
||||
assert len(calls) == 1
|
||||
args, timeout = calls[0]
|
||||
assert timeout == 180
|
||||
piece = args[-1]
|
||||
assert args[:-1] == [
|
||||
"ffmpeg", "-y",
|
||||
"-ss", "10.000",
|
||||
"-to", "50.000",
|
||||
"-i", BASE + "/movie/1/download/2026-09-26/09-00-00.mp4",
|
||||
"-c", "copy",
|
||||
]
|
||||
assert piece.endswith(".mp4")
|
||||
# temp dir created next to out_path's dir
|
||||
assert os.path.dirname(piece).startswith(str(tmp_path / "sub") + os.sep)
|
||||
assert out.exists() and out.read_bytes() == b"piece-bytes"
|
||||
assert not os.path.exists(os.path.dirname(piece)) # temp dir cleaned up
|
||||
|
||||
|
||||
def test_clip_batch_two_pieces_trim_and_concat(monkeypatch, tmp_path):
|
||||
movies = [
|
||||
{"path": CLIP_B, "start": dt("2026-09-26 09:04:00")},
|
||||
{"path": CLIP_A, "start": dt("2026-09-26 09:00:00")},
|
||||
]
|
||||
patch_batch_env(monkeypatch, movies, duration=600.0)
|
||||
calls, captured = [], {}
|
||||
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
||||
out = tmp_path / "batch.mp4"
|
||||
clip.clip_batch(BASE, 1, START_ISO, END_ISO, str(out), timeout=60)
|
||||
assert len(calls) == 3
|
||||
(piece_a, timeout_a), (piece_b, _), (concat_args, timeout_b) = calls
|
||||
assert timeout_a == timeout_b == 60
|
||||
assert piece_a[:8] == [
|
||||
"ffmpeg", "-y", "-ss", "180.000", "-to", "330.000", "-i",
|
||||
BASE + "/movie/1/download/2026-09-26/09-00-00.mp4",
|
||||
]
|
||||
assert piece_a[8:10] == ["-c", "copy"]
|
||||
assert piece_b[:8] == [
|
||||
"ffmpeg", "-y", "-ss", "0.000", "-to", "90.000", "-i",
|
||||
BASE + "/movie/1/download/2026-09-26/09-04-00.mp4",
|
||||
]
|
||||
assert piece_b[8:10] == ["-c", "copy"]
|
||||
assert concat_args == [
|
||||
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i",
|
||||
concat_args[concat_args.index("-i") + 1],
|
||||
"-c", "copy", str(out),
|
||||
]
|
||||
lines = captured["concat_list"].splitlines()
|
||||
assert len(lines) == 2
|
||||
assert all(line.startswith("file '") and line.endswith(".mp4'") for line in lines)
|
||||
assert lines[0].endswith("piece_000.mp4'")
|
||||
assert lines[1].endswith("piece_001.mp4'")
|
||||
assert out.exists()
|
||||
|
||||
|
||||
def test_clip_batch_ffmpeg_failure_raises_cliperror(monkeypatch, tmp_path):
|
||||
movies = [{"path": CLIP_A, "start": dt("2026-09-26 09:00:00")}]
|
||||
patch_batch_env(monkeypatch, movies, duration=600.0)
|
||||
|
||||
def failing_run(args, timeout):
|
||||
raise ClipError("ffmpeg gagal: ...Connection refused")
|
||||
|
||||
monkeypatch.setattr(clip, "_run_ffmpeg", failing_run)
|
||||
with pytest.raises(ClipError, match="Connection refused"):
|
||||
clip.clip_batch(BASE, 1, "2026-09-26T09:00:10", "2026-09-26T09:00:50",
|
||||
str(tmp_path / "out.mp4"))
|
||||
|
||||
|
||||
def test_clip_batch_bad_iso_raises_cliperror(tmp_path):
|
||||
with pytest.raises(ClipError):
|
||||
clip.clip_batch(BASE, 1, "bukan-tanggal", "2026-09-26T10:00:00",
|
||||
str(tmp_path / "out.mp4"))
|
||||
Reference in new issue
Block a user