diff --git a/.env.example b/.env.example index 67bc236..4a0db62 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e6fd58..ae636eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//` + trims+concatenates motionEye recordings to the batch window (`src/clip.py`), + download `b--.mp4`. ### Changed - OCR engine default **`rapid`** (was `tesseract`) across `config.yaml`, diff --git a/counter_dashboard.py b/counter_dashboard.py index d067d87..a472b27 100644 --- a/counter_dashboard.py +++ b/counter_dashboard.py @@ -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//") +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: diff --git a/docs/configuration.md b/docs/configuration.md index 4eb2453..67ad43c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 | diff --git a/docs/deployment.md b/docs/deployment.md index 697d186..d8a2094 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -87,7 +87,33 @@ Pages: `/` + `/monitoring`, `/operator` (manual start/stop; DO panel in `/api/do/{upload,photo/,staged,settings,retention}` + `PUT/DELETE /api/do/`, `/api/summary`, `/api/daily-data`, `/api/day-detail/`, `/api/recent-batches`, `/api/available-dates`, -`/api/export-daily-csv`, `/api/export-day-csv/` (Excel via openpyxl). +`/api/export-daily-csv`, `/api/export-day-csv/` (Excel via openpyxl), +`/api/batch-clip//` (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://:5000/operator` for camera capture. + +## Batch clip download (motionEye) + +History page `Klip` button → `GET /api/batch-clip//` +(**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//list/` (recording segments in the + window) and `GET /movie//download` (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_.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`). diff --git a/src/clip.py b/src/clip.py new file mode 100644 index 0000000..d6b8c15 --- /dev/null +++ b/src/clip.py @@ -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 diff --git a/templates/history.html b/templates/history.html index 1907d5b..ede72f5 100644 --- a/templates/history.html +++ b/templates/history.html @@ -269,11 +269,12 @@ Start Time End Time Duration (Min) + Klip - + Silakan pilih tanggal dari daftar sebelah kiri untuk memuat detail batch. @@ -357,7 +358,7 @@ const tableBody = document.getElementById('batchesTableBody'); tableBody.innerHTML = ` - + Memuat data batch untuk tanggal ${date}... @@ -383,7 +384,7 @@ if (!data.batches || data.batches.length === 0) { tableBody.innerHTML = ` - + Tidak ada batch tercatat pada tanggal ${date}. @@ -411,6 +412,15 @@ ${startStr} ${endStr} ${b.duration_minutes || 0} min + + + `; tableBody.appendChild(tr); }); @@ -419,7 +429,7 @@ console.error('Failed to load date details:', err); tableBody.innerHTML = ` - + Gagal memuat detail data: ${err.message} @@ -434,6 +444,46 @@ })[c]); } + // Klip: fetch office-only /api/batch-clip// (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 = ' 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); {% endblock %} diff --git a/tests/test_clip.py b/tests/test_clip.py new file mode 100644 index 0000000..4df71a1 --- /dev/null +++ b/tests/test_clip.py @@ -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"))