From cadcf8766f1d0fdd2f42a9a03447a03879c09556 Mon Sep 17 00:00:00 2001 From: sascha Date: Thu, 17 Sep 2026 15:50:16 +0200 Subject: [PATCH] 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"