From cadcf8766f1d0fdd2f42a9a03447a03879c09556 Mon Sep 17 00:00:00 2001 From: sascha Date: Thu, 17 Sep 2026 15:50:16 +0200 Subject: [PATCH 1/3] Update app.py for media verify endpoint --- app.py | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index cd59803..7549309 100644 --- a/app.py +++ b/app.py @@ -12,7 +12,7 @@ from contextlib import asynccontextmanager from contextvars import ContextVar log = logging.getLogger("butler") -VERSION = "2.4.6" +VERSION = "2.4.7" API_DIR = os.environ.get("API_KEY_DIR", "/data/api") VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache") @@ -1546,6 +1546,87 @@ async def vault_reload(_=Depends(_verify)): return {"reloaded": True, "items": len(_vault_cache)} +# --- Media file integrity verification --- + +MEDIA_VERIFY_PROBES = { + "arrapps": ("bazarrUHD", "sascha"), + "arr-chris": (None, "chris"), + "arr-chris-live": (None, "chris"), +} + + +class MediaVerifyRequest(BaseModel): + host: str = Field(..., max_length=64) + path: str = Field(..., max_length=1000) + expected_minutes: float | None = Field(None, gt=0, le=1440) + tolerance_pct: float = Field(20.0, gt=0, le=100) + + +@app.post("/media/verify") +async def media_verify(payload: MediaVerifyRequest, _=Depends(_verify)): + """Probe a media file's real duration via ffprobe to catch truncated imports. + + Runs `ffprobe` inside an existing container (bazarrUHD on arrapps, which already + mounts /data and ships ffmpeg) so no new service is required. Compares the + measured duration against an expected runtime (minutes) supplied by the caller + (e.g. Sonarr/Radarr's runtime field) within tolerance_pct. + """ + if not re.fullmatch(r"[A-Za-z0-9_.-]+", payload.host): + raise HTTPException(400, "Invalid host name") + if payload.host not in MEDIA_VERIFY_PROBES: + raise HTTPException(400, f"No ffprobe container configured for host {payload.host}") + if not re.fullmatch(r"/data/[^\x00]+\.(mkv|mp4|avi|m4v|ts)", payload.path): + raise HTTPException(400, "Path must be an absolute /data media file") + if ".." in payload.path or "'" in payload.path: + raise HTTPException(400, "Path contains unsafe characters") + + container, _default_user = MEDIA_VERIFY_PROBES[payload.host] + if not container: + raise HTTPException(400, f"Host {payload.host} has no configured ffprobe container yet") + + target = _find_inventory_host(payload.host) + if not target: + raise HTTPException(404, f"Host {payload.host} not found in inventory") + + probe_cmd = ( + f"docker exec {container} ffprobe -v error " + f"-show_entries format=duration -of default=noprint_wrappers=1:nokey=1 '{payload.path}'" + ) + rc, out, err = await asyncio.to_thread( + _ssh, f'{target["user"]}@{target["ip"]}', f"sudo -n {probe_cmd} || {probe_cmd}", 30 + ) + if rc != 0: + _audit("/media/verify", "POST", 502, f"host={payload.host} path={payload.path}") + raise HTTPException(502, (err or out).strip()[:500] or "ffprobe failed") + + raw_duration = out.strip() + try: + duration_seconds = float(raw_duration) + except ValueError: + _audit("/media/verify", "POST", 502, f"host={payload.host} unparsable duration") + raise HTTPException(502, "ffprobe returned no parsable duration; file is likely corrupt") + + duration_minutes = duration_seconds / 60 + result = { + "host": payload.host, + "path": payload.path, + "duration_seconds": round(duration_seconds, 1), + "duration_minutes": round(duration_minutes, 2), + "expected_minutes": payload.expected_minutes, + "verified": True, + "suspect": False, + } + if payload.expected_minutes: + deviation_pct = abs(duration_minutes - payload.expected_minutes) / payload.expected_minutes * 100 + result["deviation_pct"] = round(deviation_pct, 1) + result["suspect"] = deviation_pct > payload.tolerance_pct + _audit( + "/media/verify", "POST", 200, + f"host={payload.host} dur={duration_minutes:.1f}m suspect={result['suspect']}", + ) + return result + + # --- VPS reverse-proxy and DNS management --- VPS_SSH = "root@46.225.230.72" -- 2.49.1 From 8230e03745fd5745f84f781f31d1966aaa5eb55e Mon Sep 17 00:00:00 2001 From: sascha Date: Thu, 17 Sep 2026 15:50:17 +0200 Subject: [PATCH 2/3] Update tests/test_app.py for media verify endpoint --- tests/test_app.py | 84 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/test_app.py b/tests/test_app.py index adf6f9c..ae9cde0 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -901,6 +901,90 @@ def test_uptime_monitor_remove_rejects_invalid_expected_name_before_ssh(monkeypa assert response.status_code == 400 +def test_media_verify_returns_duration_and_not_suspect_when_within_tolerance(monkeypatch): + monkeypatch.setattr(app, "_find_inventory_host", lambda host: {"user": "sascha", "ip": "10.2.1.100"}) + calls = [] + + def fake_ssh(host, command, timeout=30): + calls.append((host, command, timeout)) + return 0, "2967.355000\n", "" + + monkeypatch.setattr(app, "_ssh", fake_ssh) + with TestClient(app.app) as client: + response = client.post( + "/media/verify", + headers={"Authorization": "Bearer test-token"}, + json={"host": "arrapps", "path": "/data/UHD/serien/Show/ep.mkv", "expected_minutes": 49.5}, + ) + assert response.status_code == 200 + body = response.json() + assert body["duration_minutes"] == 49.46 + assert body["suspect"] is False + assert calls[0][0] == "sascha@10.2.1.100" + assert "bazarrUHD" in calls[0][1] + assert "ffprobe" in calls[0][1] + + +def test_media_verify_flags_truncated_file_as_suspect(monkeypatch): + monkeypatch.setattr(app, "_find_inventory_host", lambda host: {"user": "sascha", "ip": "10.2.1.100"}) + monkeypatch.setattr(app, "_ssh", lambda *_args, **_kwargs: (0, "1836.0\n", "")) + with TestClient(app.app) as client: + response = client.post( + "/media/verify", + headers={"Authorization": "Bearer test-token"}, + json={"host": "arrapps", "path": "/data/UHD/serien/Show/ep.mkv", "expected_minutes": 49.5}, + ) + assert response.status_code == 200 + body = response.json() + assert body["suspect"] is True + assert body["deviation_pct"] > 20 + + +def test_media_verify_rejects_path_outside_data_before_ssh(monkeypatch): + monkeypatch.setattr(app, "_ssh", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("must not SSH"))) + with TestClient(app.app) as client: + response = client.post( + "/media/verify", + headers={"Authorization": "Bearer test-token"}, + json={"host": "arrapps", "path": "/etc/passwd"}, + ) + assert response.status_code == 400 + + +def test_media_verify_rejects_shell_metacharacters_before_ssh(monkeypatch): + monkeypatch.setattr(app, "_ssh", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("must not SSH"))) + with TestClient(app.app) as client: + response = client.post( + "/media/verify", + headers={"Authorization": "Bearer test-token"}, + json={"host": "arrapps", "path": "/data/UHD/serien/a'; rm -rf /'.mkv"}, + ) + assert response.status_code == 400 + + +def test_media_verify_rejects_unknown_host_before_ssh(monkeypatch): + monkeypatch.setattr(app, "_ssh", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("must not SSH"))) + with TestClient(app.app) as client: + response = client.post( + "/media/verify", + headers={"Authorization": "Bearer test-token"}, + json={"host": "unknown-host", "path": "/data/UHD/serien/ep.mkv"}, + ) + assert response.status_code == 400 + + +def test_media_verify_returns_502_on_unparsable_ffprobe_output(monkeypatch): + monkeypatch.setattr(app, "_find_inventory_host", lambda host: {"user": "sascha", "ip": "10.2.1.100"}) + monkeypatch.setattr(app, "_ssh", lambda *_args, **_kwargs: (0, "N/A\n", "")) + with TestClient(app.app) as client: + response = client.post( + "/media/verify", + headers={"Authorization": "Bearer test-token"}, + json={"host": "arrapps", "path": "/data/UHD/serien/ep.mkv"}, + ) + assert response.status_code == 502 + + def test_invalid_log_target_is_rejected_before_ssh(): with TestClient(app.app) as client: response = client.get( -- 2.49.1 From 1346f5f8a62e4d7a05784eac13c2c8a122f7f530 Mon Sep 17 00:00:00 2001 From: sascha Date: Thu, 17 Sep 2026 15:50:17 +0200 Subject: [PATCH 3/3] Update README.md for media verify endpoint --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index d0a464f..2b4cc1e 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Known secret response fields such as Dockhand's `hawserToken` and `webhookSecret | `/docker/inspect/{host}/{container}` | GET | Sanitized image, runtime, resources, mounts and state | | `/docker/restart/{host}/{container}` | POST | Restart container; supports `dry_run=true` | | `/config/reload` | POST | Reload YAML configuration and credential cache | +| `/media/verify` | POST | ffprobe duration check on a NAS media file to catch truncated Sonarr/Radarr imports; flags `suspect` if deviation from `expected_minutes` exceeds `tolerance_pct` | ## VM lifecycle and inventory @@ -97,6 +98,10 @@ Integration Compose definition: `tests/compose.integration.yaml` (binds only to ## Changelog +### 2.4.7 — 17.09.2026 + +- Added `POST /media/verify`: probes a media file's real duration via `ffprobe` (running inside the existing `bazarrUHD` container on `arrapps`, which already mounts `/data` and ships ffmpeg — no new service needed) and flags it as `suspect` when it deviates from an `expected_minutes` value beyond `tolerance_pct`. Catches truncated Sonarr/Radarr imports (e.g. a re-grab that lands as 1.8 GB instead of the expected 8 GB for a UHD episode) after the file is already on the NAS. + ### 2.3.2 — 22.07.2026 - Make Vaultwarden refresh durable: persistent named cache volume, protected runtime credentials, automatic API-key re-login and atomic cache writes. -- 2.49.1