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

+4
View File
@@ -28,6 +28,10 @@ SITE_NAME=LIVE
# --- Stream (canonical home; read from env, NOT config.yaml) --- # --- Stream (canonical home; read from env, NOT config.yaml) ---
RTSP_URL=rtsp://user:pass@192.168.192.209:8554/camera_stream_640 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. # --- Deprecated: model pipeline mode now lives in config.yaml models.active_mode.
# Dashboard switches persist there (atomic write, comments preserved) and apply # Dashboard switches persist there (atomic write, comments preserved) and apply
# on next `karung-counter` restart. Only set this to temporarily override. # on next `karung-counter` restart. Only set this to temporarily override.
+3
View File
@@ -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, `None (Manual)` + require-plate controls; history/export plate, DO,
expected, net columns. expected, net columns.
- `ERD.md` (Mermaid ERD, repo root; superseded `docs/do-erd.md`); tests `tests/test_do_batch.py`. - `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 ### Changed
- OCR engine default **`rapid`** (was `tesseract`) across `config.yaml`, - OCR engine default **`rapid`** (was `tesseract`) across `config.yaml`,
+111 -1
View File
@@ -11,11 +11,12 @@ import sqlite3
import time import time
from io import BytesIO from io import BytesIO
from datetime import datetime, timedelta from datetime import datetime, timedelta
from uuid import uuid4
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side 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 werkzeug.serving import WSGIRequestHandler
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -1532,6 +1533,115 @@ def api_day_detail(date):
return jsonify({"success": False, "error": str(e)}), 500 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") @app.route("/api/recent-batches")
def api_recent_batches(): def api_recent_batches():
try: try:
+2
View File
@@ -78,6 +78,8 @@ when set.
| `OFFICE_PORT` | `5721` | Second dashboard port | | `OFFICE_PORT` | `5721` | Second dashboard port |
| `FLASK_DEBUG` | `false` | Flask debug | | `FLASK_DEBUG` | `false` | Flask debug |
| `RTSP_URL` | — | Camera stream URL (env-only, never in YAML) | | `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_PATH` | — (deprecated) | Single-file v4 override, folded into `models.paths` |
| `MODEL_MODE` | — (deprecated) | One-run override of `models.active_mode` (warned) | | `MODEL_MODE` | — (deprecated) | One-run override of `models.active_mode` (warned) |
| `BATCH_MERGE_THRESHOLD_SECONDS` | `300` | Merge window for adjacent batches | | `BATCH_MERGE_THRESHOLD_SECONDS` | `300` | Merge window for adjacent batches |
+27 -1
View File
@@ -87,7 +87,33 @@ Pages: `/` + `/monitoring`, `/operator` (manual start/stop; DO panel in
`/api/do/{upload,photo/<id>,staged,settings,retention}` + `/api/do/{upload,photo/<id>,staged,settings,retention}` +
`PUT/DELETE /api/do/<id>`, `/api/summary`, `/api/daily-data`, `PUT/DELETE /api/do/<id>`, `/api/summary`, `/api/daily-data`,
`/api/day-detail/<date>`, `/api/recent-batches`, `/api/available-dates`, `/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 → 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. **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
View File
@@ -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
View File
@@ -269,11 +269,12 @@
<th>Start Time</th> <th>Start Time</th>
<th>End Time</th> <th>End Time</th>
<th>Duration (Min)</th> <th>Duration (Min)</th>
<th>Klip</th>
</tr> </tr>
</thead> </thead>
<tbody id="batchesTableBody"> <tbody id="batchesTableBody">
<tr> <tr>
<td colspan="9" class="no-data"> <td colspan="10" class="no-data">
<i class="fa-solid fa-arrow-left"></i> <i class="fa-solid fa-arrow-left"></i>
Silakan pilih tanggal dari daftar sebelah kiri untuk memuat detail batch. Silakan pilih tanggal dari daftar sebelah kiri untuk memuat detail batch.
</td> </td>
@@ -357,7 +358,7 @@
const tableBody = document.getElementById('batchesTableBody'); const tableBody = document.getElementById('batchesTableBody');
tableBody.innerHTML = ` tableBody.innerHTML = `
<tr> <tr>
<td colspan="9" class="no-data"> <td colspan="10" class="no-data">
<i class="fa-solid fa-circle-notch fa-spin"></i> <i class="fa-solid fa-circle-notch fa-spin"></i>
Memuat data batch untuk tanggal ${date}... Memuat data batch untuk tanggal ${date}...
</td> </td>
@@ -383,7 +384,7 @@
if (!data.batches || data.batches.length === 0) { if (!data.batches || data.batches.length === 0) {
tableBody.innerHTML = ` tableBody.innerHTML = `
<tr> <tr>
<td colspan="9" class="no-data"> <td colspan="10" class="no-data">
<i class="fa-solid fa-inbox"></i> <i class="fa-solid fa-inbox"></i>
Tidak ada batch tercatat pada tanggal ${date}. Tidak ada batch tercatat pada tanggal ${date}.
</td> </td>
@@ -411,6 +412,15 @@
<td>${startStr}</td> <td>${startStr}</td>
<td>${endStr}</td> <td>${endStr}</td>
<td>${b.duration_minutes || 0} min</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); tableBody.appendChild(tr);
}); });
@@ -419,7 +429,7 @@
console.error('Failed to load date details:', err); console.error('Failed to load date details:', err);
tableBody.innerHTML = ` tableBody.innerHTML = `
<tr> <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> <i class="fa-solid fa-circle-exclamation"></i>
Gagal memuat detail data: ${err.message} Gagal memuat detail data: ${err.message}
</td> </td>
@@ -434,6 +444,46 @@
})[c]); })[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); document.addEventListener('DOMContentLoaded', loadDates);
</script> </script>
{% endblock %} {% endblock %}
+275
View File
@@ -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"))