Compare commits

..

16 commits

Author SHA1 Message Date
f891b4859f Merge pull request 'Handle expected_minutes=0 gracefully (defense-in-depth)' (#61) from fix/media-verify-runtime-zero-handling into main 2026-09-18 07:19:45 +02:00
de37b51829 Handle runtime=0 gracefully in tests/test_app.py 2026-09-18 07:19:07 +02:00
686272a7c8 Handle runtime=0 gracefully in app.py 2026-09-18 07:19:06 +02:00
af19a60853 Merge pull request 'Fix /media/verify apostrophe false-positive' (#60) from fix/media-verify-apostrophe-escaping into main 2026-09-17 21:55:06 +02:00
211eab032f Fix media/verify apostrophe false-positive in README.md 2026-09-17 21:54:50 +02:00
f5c650fe3d Fix media/verify apostrophe false-positive in tests/test_app.py 2026-09-17 21:54:50 +02:00
4d48fd40c0 Fix media/verify apostrophe false-positive in app.py 2026-09-17 21:54:49 +02:00
e1dafd99b7 Merge pull request 'Add POST /media/verify - ffprobe-based truncated import detection' (#59) from feature/media-verify-endpoint into main 2026-09-17 15:50:29 +02:00
1346f5f8a6 Update README.md for media verify endpoint 2026-09-17 15:50:17 +02:00
8230e03745 Update tests/test_app.py for media verify endpoint 2026-09-17 15:50:17 +02:00
cadcf8766f Update app.py for media verify endpoint 2026-09-17 15:50:16 +02:00
453de2b037 Merge pull request 'Allow serienen/videoen in media handoff' (#58) from fix/handoff-en-categories-20260916 into main 2026-09-16 12:12:15 +02:00
Trulla
f3b9a0c33a Allow serienen/videoen in media handoff
allowed_categories kannte nur serien4k|serien|video4k|video. Die englischen
Arr-Instanzen sonarrEN (Port 8991, Root /data/FHD/serienen) und radarrEN
(Port 7880, Root /data/FHD/videoen) nutzen eigene SAB-Kategorien.

Folge: POST /media/handoff antwortete 422 "Unsupported media category",
movetdarr.sh brach mit "n8n-Handoff konnte nicht registriert werden - kein
Move" ab und Mutiny.2026 x2 (~21 GB) lagen seit 08./09.09.2026 unangetastet
in /usenet/complete/videoen. radarrEN hat den Film weiter monitored,
hasFile=false, Queue leer.

Die Pfadpruefung (expected_prefix) bleibt unveraendert und gilt auch fuer die
neuen Kategorien.

3 neue Tests: serienen+videoen werden geproxyt, erfundene Kategorie bleibt
422, Pfad-Mismatch bei videoen bleibt 422.
6/6 media_handoff-Tests gruen. test_health_exposes_current_version schlug
schon vor dieser Aenderung fehl (Test erwartet 2.4.1, VERSION ist 2.4.6).
2026-09-16 12:11:31 +02:00
f24809a22a Merge pull request 'feat: authenticated DNS RRSet upsert' (#57) from feat/dns-rrset-upsert-20260907 into main 2026-09-07 09:04:19 +02:00
8892ea4597 feat: add authenticated DNS RRSet upsert endpoint 2026-09-07 08:58:05 +02:00
161faf6c65 Merge pull request 'feat: Sascha-Emby auf Direkt-WG und h1+h2 umstellen' (#56) from feat/optimize-tv-sascha-edge-20260905 into main 2026-09-05 13:13:21 +02:00
3 changed files with 502 additions and 4 deletions

View file

@ -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/inspect/{host}/{container}` | GET | Sanitized image, runtime, resources, mounts and state |
| `/docker/restart/{host}/{container}` | POST | Restart container; supports `dry_run=true` | | `/docker/restart/{host}/{container}` | POST | Restart container; supports `dry_run=true` |
| `/config/reload` | POST | Reload YAML configuration and credential cache | | `/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 ## VM lifecycle and inventory
@ -97,6 +98,14 @@ Integration Compose definition: `tests/compose.integration.yaml` (binds only to
## Changelog ## Changelog
### 2.4.8 — 17.09.2026
- Fixed `POST /media/verify` false-positive rejection: filenames containing a legitimate apostrophe (e.g. "La'An" in a Star Trek episode title) were blocked as "unsafe characters". Replaced the naive single-quote wrapping + apostrophe blocklist with proper `shlex.quote()` escaping for the remote ffprobe command; only newline/NUL byte injection is still rejected.
### 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 ### 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. - Make Vaultwarden refresh durable: persistent named cache volume, protected runtime credentials, automatic API-key re-login and atomic cache writes.

294
app.py
View file

@ -1,7 +1,7 @@
"""Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab. """Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab.
Reads service config from butler.yaml, credentials from Vaultwarden cache with flat-file fallback.""" Reads service config from butler.yaml, credentials from Vaultwarden cache with flat-file fallback."""
import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress, secrets, sqlite3, math, hashlib import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress, secrets, sqlite3, math, hashlib, shlex
from datetime import datetime, timezone from datetime import datetime, timezone
import httpx, yaml import httpx, yaml
from typing import Literal from typing import Literal
@ -12,14 +12,17 @@ from contextlib import asynccontextmanager
from contextvars import ContextVar from contextvars import ContextVar
log = logging.getLogger("butler") log = logging.getLogger("butler")
VERSION = "2.4.1" VERSION = "2.4.9"
API_DIR = os.environ.get("API_KEY_DIR", "/data/api") API_DIR = os.environ.get("API_KEY_DIR", "/data/api")
VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache") VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache")
BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "") BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "")
CONFIG_PATH = os.environ.get("BUTLER_CONFIG", "/data/butler.yaml") CONFIG_PATH = os.environ.get("BUTLER_CONFIG", "/data/butler.yaml")
UI_PATH = os.environ.get("BUTLER_UI_PATH", os.path.join(os.path.dirname(__file__), "ui.html")) UI_PATH = os.environ.get("BUTLER_UI_PATH", os.path.join(os.path.dirname(__file__), "ui.html"))
MEDIA_HANDOFF_ALLOWED_NETWORKS = os.environ.get("MEDIA_HANDOFF_ALLOWED_NETWORKS", "10.2.1.119/32") MEDIA_HANDOFF_ALLOWED_NETWORKS = os.environ.get(
"MEDIA_HANDOFF_ALLOWED_NETWORKS",
"10.2.1.119/32,10.5.85.12/32",
)
# --- Config loading --- # --- Config loading ---
@ -1543,6 +1546,102 @@ async def vault_reload(_=Depends(_verify)):
return {"reloaded": True, "items": len(_vault_cache)} 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, ge=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 "\n" in payload.path or "\x00" 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")
# expected_minutes == 0 or None: no reference runtime available, skip verification gracefully
if not payload.expected_minutes or payload.expected_minutes <= 0:
return {
"host": payload.host,
"path": payload.path,
"duration_seconds": None,
"duration_minutes": None,
"expected_minutes": payload.expected_minutes,
"verified": False,
"suspect": False,
"skipped": True,
"skip_reason": "no_reference_runtime",
"deviation_pct": None,
}
probe_cmd = (
f"docker exec {container} ffprobe -v error "
f"-show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {shlex.quote(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 reverse-proxy and DNS management ---
VPS_SSH = "root@46.225.230.72" VPS_SSH = "root@46.225.230.72"
@ -1571,6 +1670,13 @@ class ProxyRouteRequest(BaseModel):
dns_token: str | None = None dns_token: str | None = None
class DnsRrsetUpsertRequest(BaseModel):
record_type: Literal["A", "AAAA", "CNAME"] = "A"
value: str
ttl: int = Field(default=300, ge=60, le=86400)
comment: str = Field(default="Managed by Homelab Butler", max_length=200)
def _remote_python(script: str, timeout: int = 30) -> tuple[int, str, str]: def _remote_python(script: str, timeout: int = 30) -> tuple[int, str, str]:
encoded = base64.b64encode(script.encode()).decode() encoded = base64.b64encode(script.encode()).decode()
return _ssh(VPS_SSH, f'python3 -c "import base64;exec(base64.b64decode(\'{encoded}\'))"', timeout=timeout) return _ssh(VPS_SSH, f'python3 -c "import base64;exec(base64.b64decode(\'{encoded}\'))"', timeout=timeout)
@ -3237,6 +3343,53 @@ async def _hetzner_zone_and_rrsets(zone_name: str):
return zone, rr_response.json().get("rrsets", []), headers return zone, rr_response.json().get("rrsets", []), headers
@app.put("/dns/rrset/{zone_name}/{record_name}")
async def dns_rrset_upsert(zone_name: str, record_name: str, req: DnsRrsetUpsertRequest, _=Depends(_verify)):
zone_name = zone_name.strip().lower().rstrip(".")
hostname = _validate_managed_hostname(f"{record_name}.{zone_name}")
record_name = hostname[: -(len(zone_name) + 1)]
record_type = req.record_type.upper()
value = req.value.strip().rstrip("." if record_type == "CNAME" else "")
try:
if record_type == "A" and ipaddress.ip_address(value).version != 4:
raise ValueError
if record_type == "AAAA" and ipaddress.ip_address(value).version != 6:
raise ValueError
if record_type == "CNAME":
_validate_managed_hostname(value)
except ValueError as exc:
raise HTTPException(400, f"Invalid {record_type} record value") from exc
zone, rrsets, headers = await _hetzner_zone_and_rrsets(zone_name)
existing = next((item for item in rrsets if item.get("name") == record_name and item.get("type") == record_type), None)
payload = {
"name": record_name,
"type": record_type,
"ttl": req.ttl,
"records": [{"value": value, "comment": req.comment}],
}
async with httpx.AsyncClient(timeout=30) as client:
if existing:
response = await client.put(
f"{HETZNER_DNS_API}/zones/{zone['id']}/rrsets/{record_name}/{record_type}",
headers=headers,
json=payload,
)
else:
response = await client.post(
f"{HETZNER_DNS_API}/zones/{zone['id']}/rrsets",
headers=headers,
json=payload,
)
if response.status_code not in {200, 201}:
raise HTTPException(502, f"Hetzner RRSet upsert failed: HTTP {response.status_code}")
verify = await client.get(f"{HETZNER_DNS_API}/zones/{zone['id']}/rrsets", headers=headers)
current = [item for item in verify.json().get("rrsets", []) if item.get("name") == record_name and item.get("type") == record_type] if verify.status_code == 200 else []
if not current or not any(record.get("value") == value for record in current[0].get("records", [])):
raise HTTPException(502, "RRSet read-back does not contain requested value")
_audit(f"/dns/rrset/{zone_name}/{record_name}", "PUT", 200, f"type={record_type} value={value}")
return {"zone": zone_name, "zone_id": zone.get("id"), "name": record_name, "created": existing is None, "rrset": current[0]}
@app.get("/dns/rrset/{zone_name}/{record_name}") @app.get("/dns/rrset/{zone_name}/{record_name}")
async def dns_rrset_get(zone_name: str, record_name: str, _=Depends(_verify)): async def dns_rrset_get(zone_name: str, record_name: str, _=Depends(_verify)):
zone_name = zone_name.strip().lower().rstrip(".") zone_name = zone_name.strip().lower().rstrip(".")
@ -3902,6 +4055,133 @@ async def tts_health(_=Depends(_verify)):
return results return results
def _sab_history_command() -> str:
container_script = r'''import json, re, subprocess, urllib.parse, urllib.request
text = open('/config/sabnzbd.ini', encoding='utf-8', errors='replace').read()
match = re.search(r'^api_key\s*=\s*(\S+)', text, re.M)
port_match = re.search(r'^port\s*=\s*(\d+)', text, re.M)
api_key = match.group(1) if match else ''
port = port_match.group(1) if port_match else '7777'
params = urllib.parse.urlencode({'mode': 'history', 'limit': 100, 'output': 'json', 'apikey': api_key})
with urllib.request.urlopen('http://127.0.0.1:' + port + '/api?' + params, timeout=20) as response:
data = json.load(response)
slots = data.get('history', {}).get('slots', [])
allowed = ('nzo_id', 'name', 'category', 'status', 'script', 'script_line', 'fail_message', 'completed', 'storage', 'path')
print(json.dumps([{key: item.get(key) for key in allowed} for item in slots]))
'''
container_encoded = base64.b64encode(container_script.encode()).decode()
host_script = f'''import subprocess, sys
command = ["sudo", "-n", "docker", "exec", "sabnzbd", "python3", "-c", "import base64;exec(base64.b64decode('{container_encoded}'))"]
proc = subprocess.run(command, capture_output=True, text=True, timeout=30)
if proc.returncode != 0:
command = command[2:]
proc = subprocess.run(command, capture_output=True, text=True, timeout=30)
if proc.returncode != 0:
print((proc.stderr or proc.stdout)[-500:], file=sys.stderr)
raise SystemExit(proc.returncode)
print(proc.stdout)
'''
encoded = base64.b64encode(host_script.encode()).decode()
return f'python3 -c "import base64;exec(base64.b64decode(\'{encoded}\'))"'
@app.get("/media/handoff/sab-history")
async def media_handoff_sab_history(_=Depends(_verify)):
"""Return sanitized SAB history without exposing the SAB API key."""
inventory = await asyncio.to_thread(_find_inventory_host, "sabnzbd")
if not inventory:
raise HTTPException(404, "Host sabnzbd not found")
target = f'{inventory["user"]}@{inventory["ip"]}'
rc, out, err = await asyncio.to_thread(_ssh, target, _sab_history_command(), 40)
if rc != 0:
raise HTTPException(502, (err or out).strip()[-500:] or "SAB history failed")
try:
return {"history": json.loads(out)}
except json.JSONDecodeError as exc:
raise HTTPException(502, "invalid SAB history response") from exc
def _media_handoff_diagnostics_command() -> str:
script = r'''import hashlib, json, os, subprocess, urllib.error, urllib.request
def run(args):
proc = subprocess.run(args, capture_output=True, text=True, timeout=20)
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
def docker_exec(command):
for prefix in (["sudo", "-n", "docker"], ["docker"]):
rc, out, err = run(prefix + ["exec", "sabnzbd", "sh", "-lc", command])
if rc == 0 or "not found" not in (err + out).lower():
return rc, out, err
return rc, out, err
inspect_rc, inspect_out, _ = run(["sudo", "-n", "docker", "inspect", "-f", "{{.State.Running}}", "sabnzbd"])
if inspect_rc != 0:
inspect_rc, inspect_out, _ = run(["docker", "inspect", "-f", "{{.State.Running}}", "sabnzbd"])
py_rc, py_out, _ = docker_exec("command -v python3")
curl_rc, curl_out, _ = docker_exec("command -v curl")
stat_rc, stat_out, _ = docker_exec("test -f /usenet/scripts/movetdarr.sh && stat -c '%a %s' /usenet/scripts/movetdarr.sh && sha256sum /usenet/scripts/movetdarr.sh")
log_rc, log_out, _ = docker_exec("tail -n 200 /usenet/scripts/postprocess.log 2>/dev/null || true")
source_rc, source_out, _ = docker_exec("find /usenet/complete -mindepth 2 -maxdepth 2 -type d ! -name '_UNPACK_*' -print 2>/dev/null | sort | tail -100")
target_rc, target_out, _ = docker_exec("find /tdarr/complete -mindepth 2 -maxdepth 2 -type d -print 2>/dev/null | sort | tail -100")
probe_code = 0
probe_body = ""
probe = urllib.request.Request(
"http://10.5.85.2:8888/media/handoff",
method="POST",
headers={"Content-Type": "application/json"},
data=b'{"action":"status","jobId":"diagnostic-probe"}',
)
try:
with urllib.request.urlopen(probe, timeout=10) as response:
probe_code = response.status
probe_body = response.read(500).decode(errors="replace")
except urllib.error.HTTPError as exc:
probe_code = exc.code
probe_body = exc.read(500).decode(errors="replace")
except Exception as exc:
probe_body = type(exc).__name__
script_info = {"exists": stat_rc == 0, "executable": False, "sha256": None, "mode": None, "size": None}
if stat_rc == 0:
lines = stat_out.splitlines()
if lines:
parts = lines[0].split()
if len(parts) >= 2:
script_info.update(mode=parts[0], size=int(parts[1]), executable=any(ch in parts[0][-3:] for ch in "1357"))
if len(lines) > 1:
script_info["sha256"] = lines[1].split()[0]
print(json.dumps({
"container_running": inspect_rc == 0 and inspect_out == "true",
"tools": {"python3": py_rc == 0 and bool(py_out), "curl": curl_rc == 0 and bool(curl_out)},
"script": script_info,
"caller_probe": {"http_status": probe_code, "body": probe_body},
"source_directories": source_out.splitlines() if source_rc == 0 else [],
"target_directories": target_out.splitlines() if target_rc == 0 else [],
"recent_log": log_out.splitlines()[-200:],
}))
'''
encoded = base64.b64encode(script.encode()).decode()
return f'python3 -c "import base64;exec(base64.b64decode(\'{encoded}\'))"'
@app.get("/media/handoff/diagnostics")
async def media_handoff_diagnostics(_=Depends(_verify)):
"""Read-only diagnostics for the fixed SABnzbd handoff script and caller path."""
inventory = await asyncio.to_thread(_find_inventory_host, "sabnzbd")
if not inventory:
raise HTTPException(404, "Host sabnzbd not found")
target = f'{inventory["user"]}@{inventory["ip"]}'
rc, out, err = await asyncio.to_thread(_ssh, target, _media_handoff_diagnostics_command(), 30)
if rc != 0:
raise HTTPException(502, (err or out).strip()[-500:] or "media handoff diagnostics failed")
try:
return json.loads(out)
except json.JSONDecodeError as exc:
raise HTTPException(502, "invalid media handoff diagnostic response") from exc
class MediaHandoffPayload(BaseModel): class MediaHandoffPayload(BaseModel):
action: Literal["start", "moved", "status", "fail"] action: Literal["start", "moved", "status", "fail"]
category: str | None = Field(None, max_length=32) category: str | None = Field(None, max_length=32)
@ -3933,9 +4213,15 @@ def _media_handoff_caller_allowed(request: Request) -> bool:
async def media_handoff(payload: MediaHandoffPayload, request: Request): async def media_handoff(payload: MediaHandoffPayload, request: Request):
"""Narrow SABnzbd-to-n8n bridge; no generic unauthenticated proxy access.""" """Narrow SABnzbd-to-n8n bridge; no generic unauthenticated proxy access."""
if not _media_handoff_caller_allowed(request): if not _media_handoff_caller_allowed(request):
caller = request.client.host if request.client else "unknown"
_audit("/media/handoff", "POST", 403, f"caller={caller} action={payload.action}")
raise HTTPException(403, "Media handoff caller is not allowed") raise HTTPException(403, "Media handoff caller is not allowed")
allowed_categories = {"serien4k", "serien", "video4k", "video"} # 16.09.2026: serienen/videoen ergaenzt. Die englischen Arr-Instanzen
# sonarrEN (Port 8991, Root /data/FHD/serienen) und radarrEN (Port 7880,
# Root /data/FHD/videoen) nutzen eigene SAB-Kategorien. Ohne sie brach der
# Handoff mit 422 ab und Releases blieben in /usenet/complete liegen.
allowed_categories = {"serien4k", "serien", "serienen", "video4k", "video", "videoen"}
if payload.action == "start": if payload.action == "start":
if payload.category not in allowed_categories: if payload.category not in allowed_categories:
raise HTTPException(422, "Unsupported media category") raise HTTPException(422, "Unsupported media category")

View file

@ -1,6 +1,7 @@
import os import os
import asyncio import asyncio
import json import json
import shlex
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -117,6 +118,99 @@ def test_media_handoff_rejects_wrong_category_path(monkeypatch):
assert response.status_code == 422 assert response.status_code == 422
def test_media_handoff_accepts_english_arr_categories(monkeypatch):
"""serienen/videoen muessen durchgehen (sonarrEN 8991 / radarrEN 7880).
16.09.2026: fehlten in allowed_categories -> HTTP 422 -> movetdarr.sh
brach mit "n8n-Handoff konnte nicht registriert werden" ab und liess
Mutiny.2026 x2 tagelang in /usenet/complete/videoen liegen.
"""
seen = []
class FakeResponse:
status_code = 200
def json(self):
return {"ok": True, "jobId": "test-job-5678", "state": "registered"}
class FakeClient:
def __init__(self, **_kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def post(self, url, json, headers):
seen.append(json)
return FakeResponse()
monkeypatch.setattr(app.httpx, "AsyncClient", FakeClient)
with TestClient(app.app) as client:
monkeypatch.setattr(app, "SERVICES", {"n8n": {"url": "http://n8n:5678", "auth": "n8n"}})
for category in ("serienen", "videoen"):
response = client.post(
"/media/handoff",
headers={"Authorization": "Bearer test-token"},
json={
"action": "start",
"category": category,
"directory": f"/usenet/complete/{category}/Release.2026",
"release": "Release.2026-GRP",
"cleanName": "Release 2026",
"expectedFiles": 1,
},
)
assert response.status_code == 200, (category, response.text)
assert response.json()["state"] == "registered", category
assert [item["category"] for item in seen] == ["serienen", "videoen"]
def test_media_handoff_still_rejects_unknown_category(monkeypatch):
"""Fail-closed bleibt: eine frei erfundene Kategorie wird nicht geproxyt."""
monkeypatch.setattr(
app.httpx,
"AsyncClient",
lambda **_kwargs: (_ for _ in ()).throw(AssertionError("must not proxy")),
)
with TestClient(app.app) as client:
response = client.post(
"/media/handoff",
headers={"Authorization": "Bearer test-token"},
json={
"action": "start",
"category": "hoerbuecher",
"directory": "/usenet/complete/hoerbuecher/Buch",
"release": "Buch",
},
)
assert response.status_code == 422
def test_media_handoff_english_category_path_must_match(monkeypatch):
"""Pfadpruefung gilt auch fuer die neuen Kategorien."""
monkeypatch.setattr(
app.httpx,
"AsyncClient",
lambda **_kwargs: (_ for _ in ()).throw(AssertionError("must not proxy")),
)
with TestClient(app.app) as client:
response = client.post(
"/media/handoff",
headers={"Authorization": "Bearer test-token"},
json={
"action": "start",
"category": "videoen",
"directory": "/usenet/complete/video4k/Wrong",
"release": "Wrong",
},
)
assert response.status_code == 422
def test_paperless_import_queues_pdf_through_butler(monkeypatch): def test_paperless_import_queues_pdf_through_butler(monkeypatch):
captured = {} captured = {}
@ -808,6 +902,115 @@ def test_uptime_monitor_remove_rejects_invalid_expected_name_before_ssh(monkeypa
assert response.status_code == 400 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_allows_apostrophe_in_filename_and_quotes_it_safely(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.0\n", ""
monkeypatch.setattr(app, "_ssh", fake_ssh)
path = "/data/FHD/serien/Star Trek - Strange New Worlds (2022)/Season 04/Once La'An a Time.mkv"
with TestClient(app.app) as client:
response = client.post(
"/media/verify",
headers={"Authorization": "Bearer test-token"},
json={"host": "arrapps", "path": path, "expected_minutes": 49.5},
)
assert response.status_code == 200
# shlex.quote must produce a command the remote shell parses as ONE argument,
# i.e. no unescaped apostrophe breaks out of quoting.
executed_cmd = calls[0][1]
quoted = shlex.quote(path)
assert quoted in executed_cmd
assert shlex.split(executed_cmd)[-1] == path
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\x00.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", "expected_minutes": 49.5},
)
assert response.status_code == 502
def test_invalid_log_target_is_rejected_before_ssh(): def test_invalid_log_target_is_rejected_before_ssh():
with TestClient(app.app) as client: with TestClient(app.app) as client:
response = client.get( response = client.get(