Sicherheits-Härtung und Bugfixes

This commit is contained in:
Patrick Asmus
2026-10-11 17:39:03 +02:00
parent c3f68ace96
commit 65b0b5ac0f
25 changed files with 2577 additions and 686 deletions
+613 -202
View File
File diff suppressed because it is too large Load Diff
+119 -19
View File
@@ -1,9 +1,18 @@
import base64
import hashlib
import hmac
import os
import secrets
import tempfile
import threading
import yaml
DEFAULT_DOWNLOAD_PATH = "/app/data/recordings"
DEFAULT_CONFIG = {
"recording": {
"download_path": "/app/data/recordings",
"download_path": DEFAULT_DOWNLOAD_PATH,
"max_retries": 5,
"retry_delay": 5,
},
@@ -14,7 +23,7 @@ DEFAULT_CONFIG = {
"auth": {
"enabled": False,
"username": "admin",
"password": "stream-recorder",
"password_hash": "",
},
"ntfy": {
"url": "",
@@ -35,35 +44,126 @@ DEFAULT_CONFIG = {
},
}
PBKDF2_ITERATIONS = 600000
LEGACY_DEFAULT_PASSWORD = "stream-recorder"
def hash_password(password: str, iterations: int | None = None) -> str:
iterations = iterations or PBKDF2_ITERATIONS
salt = secrets.token_bytes(16)
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return "pbkdf2_sha256${}${}${}".format(
iterations,
base64.b64encode(salt).decode("ascii"),
base64.b64encode(dk).decode("ascii"),
)
def verify_password(password: str, encoded: str) -> bool:
try:
algo, iterations, salt_b64, hash_b64 = encoded.split("$", 3)
if algo != "pbkdf2_sha256":
return False
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(hash_b64)
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations))
return hmac.compare_digest(dk, expected)
except Exception:
return False
def recording_roots() -> list[str]:
raw = os.environ.get("RECORDING_ROOTS", os.pathsep.join(["/app/data/recordings", "/recordings"]))
return [os.path.realpath(r) for r in raw.split(os.pathsep) if r.strip()]
def validate_download_path(path: str) -> str:
"""Gibt den normalisierten Pfad zurück oder wirft ValueError."""
if not isinstance(path, str) or not path.strip():
raise ValueError("Download-Pfad darf nicht leer sein")
if "\x00" in path:
raise ValueError("Download-Pfad enthält ungültige Zeichen")
if not os.path.isabs(path):
raise ValueError("Download-Pfad muss absolut sein")
real = os.path.realpath(path)
roots = recording_roots()
for root in roots:
if real == root or real.startswith(root.rstrip(os.sep) + os.sep):
return real
raise ValueError(f"Download-Pfad muss innerhalb von {', '.join(roots)} liegen")
class Config:
def __init__(self, config_path: str = "/app/data/config.yml"):
self.config_path = config_path
self.data = {}
self.lock = threading.RLock()
self.generated_password = None
self.password_migrated = False
self.default_password_active = False
self.load()
def load(self):
self.data = _deep_copy(DEFAULT_CONFIG)
if os.path.exists(self.config_path):
with open(self.config_path, "r") as f:
user_config = yaml.safe_load(f) or {}
_deep_merge(self.data, user_config)
else:
self.save()
with self.lock:
self.data = _deep_copy(DEFAULT_CONFIG)
if os.path.exists(self.config_path):
with open(self.config_path, "r", encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
_deep_merge(self.data, user_config)
auth = self.data.setdefault("auth", {})
if "password" in auth:
plain = auth.pop("password")
if plain and not auth.get("password_hash"):
auth["password_hash"] = hash_password(str(plain))
self.password_migrated = True
self.save()
# Altes Standardpasswort gilt als nicht gesetzt, sonst würde
# "Auth aktivieren" ohne neues Passwort ein bekanntes Passwort scharf schalten.
pw_hash = auth.get("password_hash")
if pw_hash and verify_password(LEGACY_DEFAULT_PASSWORD, pw_hash):
if auth.get("enabled"):
self.default_password_active = True
else:
auth["password_hash"] = ""
self.save()
else:
password = secrets.token_urlsafe(12)
self.data["auth"]["enabled"] = True
self.data["auth"]["username"] = "admin"
self.data["auth"]["password_hash"] = hash_password(password)
self.generated_password = password
self.save()
def save(self):
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
with open(self.config_path, "w") as f:
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
with self.lock:
directory = os.path.dirname(self.config_path) or "."
os.makedirs(directory, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".config.", suffix=".tmp", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
f.flush()
os.fsync(f.fileno())
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, self.config_path)
except BaseException:
try:
os.remove(tmp_path)
except OSError:
pass
raise
def get(self, *keys, default=None):
val = self.data
for key in keys:
if isinstance(val, dict) and key in val:
val = val[key]
else:
return default
return val
with self.lock:
val = self.data
for key in keys:
if isinstance(val, dict) and key in val:
val = val[key]
else:
return default
return val
def _deep_copy(d):
+56 -8
View File
@@ -1,9 +1,25 @@
import datetime
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Text, text
import logging
from sqlalchemy import create_engine, event, Column, Integer, String, Boolean, DateTime, Text, text
from sqlalchemy.orm import declarative_base, sessionmaker
Base = declarative_base()
logger = logging.getLogger("models")
def utcnow() -> datetime.datetime:
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
def iso_utc(dt: datetime.datetime | None) -> str | None:
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=datetime.timezone.utc)
return dt.astimezone(datetime.timezone.utc).isoformat()
class Job(Base):
__tablename__ = "jobs"
@@ -37,8 +53,8 @@ class Job(Base):
station_id = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
class Station(Base):
@@ -49,8 +65,8 @@ class Station(Base):
stream_url = Column(String(1000), nullable=False)
stream_type = Column(String(20), default="auto")
description = Column(Text, default="")
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
class Recording(Base):
@@ -65,7 +81,7 @@ class Recording(Base):
output_file = Column(String(500), default="")
pid = Column(Integer, nullable=True)
status = Column(String(20), default="running") # running, stopped, completed, error
started_at = Column(DateTime, default=datetime.datetime.utcnow)
started_at = Column(DateTime, default=utcnow)
stopped_at = Column(DateTime, nullable=True)
scheduled_stop = Column(DateTime, nullable=True)
segment_mode = Column(Boolean, default=False)
@@ -79,7 +95,7 @@ class LogEntry(Base):
__tablename__ = "logs"
id = Column(Integer, primary_key=True, autoincrement=True)
timestamp = Column(DateTime, default=datetime.datetime.utcnow)
timestamp = Column(DateTime, default=utcnow)
level = Column(String(10), default="INFO")
message = Column(Text, nullable=False)
job_name = Column(String(200), nullable=True)
@@ -91,7 +107,20 @@ _SessionLocal = None
def init_db(db_path: str):
global _engine, _SessionLocal
_engine = create_engine(f"sqlite:///{db_path}", connect_args={"check_same_thread": False})
_engine = create_engine(
f"sqlite:///{db_path}",
connect_args={"check_same_thread": False, "timeout": 30},
)
@event.listens_for(_engine, "connect")
def _set_sqlite_pragmas(dbapi_conn, _record):
cur = dbapi_conn.cursor()
try:
cur.execute("PRAGMA journal_mode=WAL")
cur.execute("PRAGMA busy_timeout=5000")
finally:
cur.close()
Base.metadata.create_all(_engine)
_SessionLocal = sessionmaker(bind=_engine)
_migrate(_engine)
@@ -115,3 +144,22 @@ def _migrate(engine):
def get_session():
return _SessionLocal()
def prune_logs(days: int) -> int:
if days <= 0:
return 0
cutoff = utcnow() - datetime.timedelta(days=days)
session = get_session()
try:
count = session.query(LogEntry).filter(LogEntry.timestamp < cutoff).delete(synchronize_session=False)
session.commit()
if count:
logger.info("%d alte Log-Einträge gelöscht (älter als %d Tage)", count, days)
return count
except Exception as e:
session.rollback()
logger.error("Log-Bereinigung fehlgeschlagen: %s", e)
return 0
finally:
session.close()
+28 -22
View File
@@ -1,8 +1,12 @@
"""NTFY push notification plugin."""
import subprocess
import logging
import threading
import httpx
logger = logging.getLogger("plugins.ntfy")
EVENT_TAGS = {
"start": "red_circle",
"stop": "white_check_mark",
@@ -11,6 +15,23 @@ EVENT_TAGS = {
}
def _post(url: str, title: str, message: str, token: str, priority: str, tags: str):
params = {"title": title, "priority": priority or "default"}
if tags:
params["tags"] = tags
headers = {"Content-Type": "text/plain; charset=utf-8"}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
resp = httpx.post(url, params=params, content=message.encode("utf-8"),
headers=headers, timeout=10.0)
if resp.status_code >= 400:
logger.warning("NTFY-Benachrichtigung fehlgeschlagen: HTTP %s %s",
resp.status_code, resp.text[:200])
except Exception as e:
logger.warning("NTFY-Benachrichtigung fehlgeschlagen: %s", e)
def send_notification(
url: str,
title: str,
@@ -21,26 +42,11 @@ def send_notification(
):
if not url:
return
cmd = [
"curl", "-s", "-o", "/dev/null", "--max-time", "10",
"-H", f"Title: {title}",
"-H", f"Priority: {priority}",
]
if tags:
cmd += ["-H", f"Tags: {tags}"]
cmd += ["-d", message]
if token:
cmd += ["-H", f"Authorization: Bearer {token}"]
cmd.append(url)
def _send():
try:
subprocess.run(cmd, capture_output=True, timeout=15, stdin=subprocess.DEVNULL)
except Exception:
pass
threading.Thread(target=_send, daemon=True).start()
threading.Thread(
target=_post,
args=(url, title, message, token, priority, tags),
daemon=True,
).start()
class NtfyNotifier:
@@ -53,7 +59,7 @@ class NtfyNotifier:
url = self.config.get("ntfy", "url", default="")
if not url:
return False
events = self.config.get("ntfy", "events", default="error")
events = self.config.get("ntfy", "events", default="error") or ""
return event in [e.strip() for e in events.split(",")]
def notify(self, event: str, title: str, message: str, job_ntfy_enabled: bool = False, priority: str = "default"):
+89 -96
View File
@@ -3,10 +3,13 @@
import json
import os
import re
import subprocess
import threading
from typing import Callable
import httpx
UPLOAD_TIMEOUT = httpx.Timeout(7200.0, connect=30.0)
def _parse_ttl_seconds(ttl: str) -> int | None:
if not ttl:
@@ -23,6 +26,41 @@ def _parse_ttl_seconds(ttl: str) -> int | None:
return None
def _parse_response(body: str, plik_base: str, filename: str) -> dict:
try:
data = json.loads(body)
except (json.JSONDecodeError, ValueError):
data = None
if isinstance(data, dict):
if "error" in data:
return {"error": f"Plik-Server: {data['error']}"}
upload_id = data.get("id", "")
if not upload_id:
return {"error": f"Keine Upload-ID in Antwort: {body[:300]}"}
browser_url = f"{plik_base}/#/?id={upload_id}"
download_url = None
files = data.get("files", [])
if files:
file_id = files[0].get("id", "")
file_name = files[0].get("fileName", filename)
if file_id:
download_url = f"{plik_base}/file/{upload_id}/{file_id}/{file_name}"
return {"browser_url": browser_url, "download_url": download_url}
urls = re.findall(r'https?://\S+', body)
if urls:
download_url = urls[-1]
prefix = f"{plik_base}/file/"
if download_url.startswith(prefix):
uid = download_url[len(prefix):].split("/")[0]
if uid:
return {"browser_url": f"{plik_base}/#/?id={uid}", "download_url": download_url}
return {"browser_url": download_url, "download_url": download_url}
return {"error": f"Antwort nicht lesbar: {body[:300]}"}
def upload_file(
file_path: str,
plik_url: str,
@@ -40,58 +78,28 @@ def upload_file(
plik_base = plik_url.rstrip("/")
filename = os.path.basename(file_path)
cmd = ["curl", "-sS", "--fail-with-body", "--max-time", "7200"]
headers = {}
if api_key:
cmd += ["-H", f"X-PlikToken: {api_key}"]
cmd += ["-F", f"file=@{file_path};filename={filename}"]
headers["X-PlikToken"] = api_key
form = {}
ttl_secs = _parse_ttl_seconds(ttl)
if ttl_secs is not None:
cmd += ["-F", f"ttl={ttl_secs}"]
cmd.append(plik_base)
form["ttl"] = str(ttl_secs)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=7200, stdin=subprocess.DEVNULL)
if result.returncode != 0:
err = result.stderr.strip() or result.stdout.strip() or f"curl exit code {result.returncode}"
return {"error": f"curl-Fehler: {err[:500]}"}
try:
data = json.loads(result.stdout)
if "error" in data:
return {"error": f"Plik-Server: {data['error']}"}
upload_id = data.get("id", "")
if not upload_id:
return {"error": f"Keine Upload-ID in Antwort: {result.stdout[:300]}"}
browser_url = f"{plik_base}/#/?id={upload_id}"
download_url = None
files = data.get("files", [])
if files:
file_id = files[0].get("id", "")
file_name = files[0].get("fileName", filename)
if file_id:
download_url = f"{plik_base}/file/{upload_id}/{file_id}/{file_name}"
return {"browser_url": browser_url, "download_url": download_url}
except (json.JSONDecodeError, KeyError):
urls = re.findall(r'https?://\S+', result.stdout)
if urls:
download_url = urls[-1]
prefix = f"{plik_base}/file/"
if download_url.startswith(prefix):
rest = download_url[len(prefix):]
uid = rest.split("/")[0]
if uid:
return {"browser_url": f"{plik_base}/#/?id={uid}", "download_url": download_url}
return {"browser_url": download_url, "download_url": download_url}
return {"error": f"Antwort nicht lesbar: {result.stdout[:300]}"}
except subprocess.TimeoutExpired:
with open(file_path, "rb") as fh:
resp = httpx.post(
plik_base,
headers=headers,
data=form,
files={"file": (filename, fh, "application/octet-stream")},
timeout=UPLOAD_TIMEOUT,
)
if resp.status_code >= 400:
return {"error": f"HTTP {resp.status_code}: {resp.text.strip()[:500]}"}
return _parse_response(resp.text, plik_base, filename)
except httpx.TimeoutException:
return {"error": "Upload-Timeout (max. 2h)"}
except Exception as e:
return {"error": str(e)}
@@ -103,9 +111,8 @@ class PlikUploader:
self._log = log_fn
self._notify = notify_fn
def upload_recording(self, output_dir: str, output_file: str, job_name: str,
segment_mode: bool, delete_after: bool, ntfy_enabled: bool,
save_url_fn: Callable = None):
def upload_recording(self, files: list[str], job_name: str, segment_mode: bool,
delete_after: bool, ntfy_enabled: bool, save_url_fn: Callable = None):
plik_url = self.config.get("plik", "url", default="")
if not plik_url:
self._log("WARN", f"Plik-Upload übersprungen: Keine URL konfiguriert [{job_name}]", job_name)
@@ -113,75 +120,61 @@ class PlikUploader:
api_key = self.config.get("plik", "api_key", default="")
ttl = self.config.get("plik", "ttl", default="30d")
files = [f for f in files if not os.path.basename(f).startswith(".")]
def _do_upload():
try:
if segment_mode:
self._upload_segments(output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn)
else:
self._upload_single(output_file, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn)
self._upload_files(files, job_name, segment_mode, plik_url, api_key, ttl,
delete_after, ntfy_enabled, save_url_fn)
except Exception as e:
self._log("ERROR", f"Plik-Upload Fehler: {e} [{job_name}]", job_name)
threading.Thread(target=_do_upload, daemon=True).start()
def _upload_single(self, file_path, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn=None):
if not os.path.isfile(file_path) or os.path.getsize(file_path) == 0:
def _remove(self, path: str, job_name: str):
try:
os.remove(path)
self._log("INFO", f"Lokale Datei gelöscht: {os.path.basename(path)} [{job_name}]", job_name)
except OSError as e:
self._log("ERROR", f"Lokale Datei konnte nicht gelöscht werden: {path} - {e} [{job_name}]", job_name)
def _upload_files(self, files, job_name, segment_mode, plik_url, api_key, ttl,
delete_after, ntfy_enabled, save_url_fn=None):
files = [f for f in files if os.path.isfile(f) and os.path.getsize(f) > 0]
if not files:
self._log("WARN", f"Plik-Upload übersprungen: Datei fehlt oder leer [{job_name}]", job_name)
return
self._log("INFO", f"Plik-Upload: {os.path.basename(file_path)} [{job_name}]", job_name)
result = upload_file(file_path, plik_url, api_key, ttl)
if result and "error" in result:
self._log("ERROR", f"Plik-Upload fehlgeschlagen: {result['error']} [{job_name}]", job_name)
elif result and result.get("browser_url"):
self._log("INFO", f"Plik-Upload abgeschlossen: {result['browser_url']} [{job_name}]", job_name)
if result.get("download_url"):
self._log("INFO", f"Plik-Download: {result['download_url']} [{job_name}]", job_name)
if save_url_fn:
save_url_fn(result["browser_url"])
if self._notify:
self._notify("upload", "Plik-Upload", f"{job_name}: {result['browser_url']}", ntfy_enabled)
if delete_after:
os.remove(file_path)
self._log("INFO", f"Lokale Datei gelöscht: {file_path} [{job_name}]", job_name)
else:
self._log("ERROR", f"Plik-Upload fehlgeschlagen (unbekannter Fehler) [{job_name}]", job_name)
def _upload_segments(self, output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn=None):
if not os.path.isdir(output_dir):
return
files = sorted(f for f in os.listdir(output_dir) if not f.startswith(".") and os.path.isfile(os.path.join(output_dir, f)))
if not files:
return
label = "Segment" if segment_mode else "Teil"
multi = len(files) > 1
uploaded = 0
last_browser_url = None
for fname in files:
fpath = os.path.join(output_dir, fname)
if os.path.getsize(fpath) == 0:
continue
self._log("INFO", f"Plik Segment-Upload: {fname} [{job_name}]", job_name)
for fpath in files:
fname = os.path.basename(fpath)
prefix = f"Plik {label}-Upload" if multi else "Plik-Upload"
self._log("INFO", f"{prefix}: {fname} [{job_name}]", job_name)
result = upload_file(fpath, plik_url, api_key, ttl)
if result and "error" in result:
self._log("ERROR", f"Plik Segment-Upload fehlgeschlagen: {fname} - {result['error']} [{job_name}]", job_name)
self._log("ERROR", f"{prefix} fehlgeschlagen: {fname} - {result['error']} [{job_name}]", job_name)
elif result and result.get("browser_url"):
last_browser_url = result["browser_url"]
self._log("INFO", f"Plik Segment hochgeladen: {last_browser_url} [{job_name}]", job_name)
self._log("INFO", f"{prefix} abgeschlossen: {last_browser_url} [{job_name}]", job_name)
if result.get("download_url"):
self._log("INFO", f"Plik-Download: {result['download_url']} [{job_name}]", job_name)
uploaded += 1
if delete_after:
os.remove(fpath)
self._log("INFO", f"Segment gelöscht: {fname} [{job_name}]", job_name)
self._remove(fpath, job_name)
else:
self._log("ERROR", f"Plik Segment-Upload fehlgeschlagen: {fname} (unbekannter Fehler) [{job_name}]", job_name)
self._log("ERROR", f"{prefix} fehlgeschlagen: {fname} (unbekannter Fehler) [{job_name}]", job_name)
if uploaded > 0:
if save_url_fn and last_browser_url:
save_url_fn(last_browser_url)
if self._notify:
msg = f"{job_name}: {uploaded} Segment(e) hochgeladen"
if last_browser_url:
msg += f"\n{last_browser_url}"
if multi:
msg = f"{job_name}: {uploaded} {label}(e) hochgeladen"
if last_browser_url:
msg += f"\n{last_browser_url}"
else:
msg = f"{job_name}: {last_browser_url}"
self._notify("upload", "Plik-Upload", msg, ntfy_enabled)
+170 -44
View File
@@ -1,16 +1,95 @@
import asyncio
import datetime
import glob as globmod
import logging
import os
import re
import shlex
import signal
import subprocess
import threading
import time
from pathlib import Path
from typing import Optional
from urllib.parse import urlsplit
from models import Recording, get_session, LogEntry
from config import DEFAULT_DOWNLOAD_PATH, validate_download_path
from models import Recording, get_session, LogEntry, iso_utc, utcnow
logger = logging.getLogger("recorder")
ALLOWED_URL_SCHEMES = ("http", "https", "rtmp", "rtmps", "rtsp", "rtsps")
PROTOCOL_WHITELIST = "http,https,tcp,tls,crypto,hls,httpproxy,rtmp,rtmps,rtmpt,rtmpts,ffrtmphttp,rtp,udp"
FFMPEG_FLAGS = {"-vn", "-an", "-sn", "-dn", "-shortest", "-copyts"}
FFMPEG_VALUE_OPTIONS = {
"-c", "-c:a", "-c:v", "-codec", "-codec:a", "-codec:v", "-acodec", "-vcodec",
"-b:a", "-b:v", "-ar", "-ac", "-map", "-metadata", "-metadata:s:a", "-t", "-to",
"-q:a", "-aq", "-af", "-filter:a", "-bsf:a", "-bsf:v", "-f", "-movflags",
"-segment_format", "-fflags", "-avoid_negative_ts", "-max_muxing_queue_size",
}
FORBIDDEN_FILTER_WORDS = ("movie", "sendcmd", "zmq", "file")
MAX_FFMPEG_ARGS_LEN = 500
MIN_STABLE_RUNTIME = 60
STALE_ERROR_MESSAGE = "Abgebrochen durch Neustart"
def validate_ffmpeg_args(args: str) -> list[str]:
"""Prüft zusätzliche ffmpeg-Argumente gegen eine Allowlist. Wirft ValueError."""
if not args:
return []
if len(args) > MAX_FFMPEG_ARGS_LEN:
raise ValueError(f"Zusätzliche ffmpeg-Argumente sind zu lang (max. {MAX_FFMPEG_ARGS_LEN} Zeichen)")
try:
tokens = shlex.split(args)
except ValueError as e:
raise ValueError(f"Zusätzliche ffmpeg-Argumente nicht lesbar: {e}")
i = 0
while i < len(tokens):
tok = tokens[i]
if tok in FFMPEG_FLAGS:
i += 1
continue
if tok in FFMPEG_VALUE_OPTIONS:
if i + 1 >= len(tokens):
raise ValueError(f"ffmpeg-Option '{tok}' benötigt einen Wert")
val = tokens[i + 1]
if not val or val.startswith("-"):
raise ValueError(f"Ungültiger Wert für ffmpeg-Option '{tok}': '{val}'")
if tok in ("-af", "-filter:a"):
low = val.lower()
for word in FORBIDDEN_FILTER_WORDS:
if word in low:
raise ValueError(f"Filter '{val}' ist nicht erlaubt ('{word}')")
i += 2
continue
raise ValueError(f"ffmpeg-Argument nicht erlaubt: '{tok}'")
return tokens
def validate_stream_url(url: str) -> str:
"""Prüft eine Stream-URL. Wirft ValueError."""
if not isinstance(url, str):
raise ValueError("Stream-URL fehlt")
url = url.strip()
if not url:
raise ValueError("Stream-URL fehlt")
if len(url) > 1000:
raise ValueError("Stream-URL ist zu lang (max. 1000 Zeichen)")
if any(ch.isspace() or ord(ch) < 32 or ord(ch) == 127 for ch in url):
raise ValueError("Stream-URL enthält Leer- oder Steuerzeichen")
try:
parts = urlsplit(url)
hostname = parts.hostname
except ValueError:
raise ValueError("Stream-URL ist ungültig")
if parts.scheme.lower() not in ALLOWED_URL_SCHEMES:
raise ValueError(f"Stream-URL muss mit {', '.join(s + '://' for s in ALLOWED_URL_SCHEMES)} beginnen")
if not parts.netloc or not hostname:
raise ValueError("Stream-URL enthält keinen Host")
return url
def detect_stream_type(url: str) -> str:
@@ -96,14 +175,27 @@ class RecordingManager:
self._get_ntfy().notify(event, title, message, job_ntfy_enabled=ntfy_enabled, priority=priority)
def _log(self, level: str, message: str, job_name: str = None):
py_level = logging.WARNING if level == "WARN" else getattr(logging, level, logging.INFO)
logger.log(py_level, message)
session = get_session()
try:
entry = LogEntry(level=level, message=message, job_name=job_name)
session.add(entry)
session.commit()
except Exception as e:
session.rollback()
logger.error("Log-Eintrag konnte nicht gespeichert werden: %s", e)
finally:
session.close()
def _download_path(self) -> str:
configured = self.config.get("recording", "download_path", default=DEFAULT_DOWNLOAD_PATH)
try:
return validate_download_path(configured)
except ValueError as e:
self._log("ERROR", f"Ungültiger Download-Pfad '{configured}': {e} – verwende {DEFAULT_DOWNLOAD_PATH}")
return DEFAULT_DOWNLOAD_PATH
def start_recording(
self,
job_id: Optional[int],
@@ -120,13 +212,16 @@ class RecordingManager:
plik_enabled: bool = False,
delete_after_upload: bool = False,
) -> dict:
if stream_type == "auto":
stream_url = validate_stream_url(stream_url)
validate_ffmpeg_args(extra_ffmpeg_args or "")
if not stream_type or stream_type == "auto":
stream_type = detect_stream_type(stream_url)
ext = output_format if output_format and output_format.lower() != "auto" else default_extension(stream_type)
sname = safe_name(name)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
download_path = self.config.get("recording", "download_path", default="/app/data/recordings")
download_path = self._download_path()
output_dir = os.path.join(download_path, sname)
os.makedirs(output_dir, exist_ok=True)
@@ -139,7 +234,7 @@ class RecordingManager:
scheduled_stop = None
if max_duration and max_duration > 0:
scheduled_stop = datetime.datetime.utcnow() + datetime.timedelta(seconds=max_duration)
scheduled_stop = utcnow() + datetime.timedelta(seconds=max_duration)
session = get_session()
try:
@@ -206,12 +301,12 @@ class RecordingManager:
try:
while not stop_event.is_set():
cmd = self._build_ffmpeg_cmd(
stream_url, stream_type, output_file,
extra_ffmpeg_args, segment_mode, segment_duration,
)
log_fh = None
try:
cmd = self._build_ffmpeg_cmd(
stream_url, stream_type, output_file,
extra_ffmpeg_args, segment_mode, segment_duration,
)
log_path = os.path.join(output_dir, f".ffmpeg_{rec_id}.log")
log_fh = open(log_path, "a")
process = subprocess.Popen(
@@ -221,6 +316,8 @@ class RecordingManager:
stdin=subprocess.DEVNULL,
)
except Exception as e:
if log_fh:
log_fh.close()
self._update_recording(rec_id, status="error", error_message=str(e),
file_size=self._finalize_file_size(rec_id, segment_mode))
self._log("ERROR", f"ffmpeg konnte nicht gestartet werden: {e}", job_name)
@@ -229,6 +326,7 @@ class RecordingManager:
self._processes[rec_id] = process
self._update_recording(rec_id, status="running", pid=process.pid)
proc_started = time.monotonic()
process.wait()
retcode = process.returncode
@@ -253,6 +351,8 @@ class RecordingManager:
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
return
if time.monotonic() - proc_started >= MIN_STABLE_RUNTIME:
retry = 0
retry += 1
if max_retries > 0 and retry >= max_retries:
self._update_recording(rec_id, status="error",
@@ -290,7 +390,11 @@ class RecordingManager:
self._cleanup(rec_id)
def _build_ffmpeg_cmd(self, url, stream_type, output_file, extra_args, segment_mode, segment_duration):
cmd = ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "warning"]
url = validate_stream_url(url)
extra_tokens = validate_ffmpeg_args(extra_args or "")
cmd = ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "warning",
"-protocol_whitelist", PROTOCOL_WHITELIST]
if stream_type == "hls":
cmd += ["-i", url, "-c", "copy", "-bsf:a", "aac_adtstoasc"]
@@ -300,8 +404,7 @@ class RecordingManager:
if segment_mode and segment_duration:
cmd += ["-f", "segment", "-segment_time", str(segment_duration), "-reset_timestamps", "1"]
if extra_args:
cmd += extra_args.split()
cmd += extra_tokens
cmd.append(output_file)
return cmd
@@ -328,23 +431,25 @@ class RecordingManager:
self._cleanup(rec_id)
return True
def extend_recording(self, rec_id: int, extra_seconds: int) -> bool:
def extend_recording(self, rec_id: int, extra_seconds: int) -> str:
"""Ergebnis: "ok", "not_found", "not_running" oder "unlimited"."""
session = get_session()
try:
rec = session.query(Recording).filter_by(id=rec_id).first()
if not rec or rec.status != "running":
return False
if rec.scheduled_stop:
rec.scheduled_stop = rec.scheduled_stop + datetime.timedelta(seconds=extra_seconds)
else:
rec.scheduled_stop = datetime.datetime.utcnow() + datetime.timedelta(seconds=extra_seconds)
if not rec:
return "not_found"
if rec.status != "running":
return "not_running"
if not rec.scheduled_stop:
return "unlimited"
rec.scheduled_stop = rec.scheduled_stop + datetime.timedelta(seconds=extra_seconds)
job_name = rec.job_name
session.commit()
self._log("INFO", f"Aufnahme verlängert um {format_elapsed(extra_seconds)}: {rec.job_name}", rec.job_name)
return True
finally:
session.close()
self._log("INFO", f"Aufnahme verlängert um {format_elapsed(extra_seconds)}: {job_name}", job_name)
return "ok"
def remove_duration_limit(self, rec_id: int) -> bool:
session = get_session()
@@ -388,7 +493,7 @@ class RecordingManager:
def check_scheduled_stops(self):
session = get_session()
try:
now = datetime.datetime.utcnow()
now = utcnow()
recs = session.query(Recording).filter(
Recording.status == "running",
Recording.scheduled_stop.isnot(None),
@@ -403,25 +508,30 @@ class RecordingManager:
self.stop_recording(rec_id)
def cleanup_stale(self):
# Nach einem Neustart kann kein ffmpeg-Kindprozess mehr laufen
session = get_session()
try:
recs = session.query(Recording).filter(Recording.status.in_(["running", "starting"])).all()
now = utcnow()
for rec in recs:
if rec.pid:
try:
os.kill(rec.pid, 0)
except (ProcessLookupError, OSError):
rec.status = "error"
rec.error_message = "Process vanished"
rec.stopped_at = datetime.datetime.utcnow()
rec.status = "error"
rec.error_message = STALE_ERROR_MESSAGE
rec.stopped_at = now
try:
files = [rec.output_file] if rec.output_file else []
rec.file_size, _ = self._calc_recording_size(files, bool(rec.segment_mode))
except Exception:
pass
session.commit()
if recs:
logger.warning("%d verwaiste Aufnahme(n) als abgebrochen markiert", len(recs))
finally:
session.close()
def _recording_to_dict(self, rec: Recording) -> dict:
elapsed = 0
if rec.started_at:
end = rec.stopped_at or datetime.datetime.utcnow()
end = rec.stopped_at or utcnow()
elapsed = int((end - rec.started_at).total_seconds())
total_size = 0
@@ -434,7 +544,7 @@ class RecordingManager:
remaining = None
if rec.scheduled_stop and rec.status == "running":
diff = (rec.scheduled_stop - datetime.datetime.utcnow()).total_seconds()
diff = (rec.scheduled_stop - utcnow()).total_seconds()
remaining = max(0, int(diff))
return {
@@ -447,9 +557,9 @@ class RecordingManager:
"output_file": rec.output_file,
"pid": rec.pid,
"status": rec.status,
"started_at": rec.started_at.isoformat() if rec.started_at else None,
"stopped_at": rec.stopped_at.isoformat() if rec.stopped_at else None,
"scheduled_stop": rec.scheduled_stop.isoformat() if rec.scheduled_stop else None,
"started_at": iso_utc(rec.started_at),
"stopped_at": iso_utc(rec.stopped_at),
"scheduled_stop": iso_utc(rec.scheduled_stop),
"segment_mode": rec.segment_mode,
"is_scheduled": rec.is_scheduled,
"error_message": rec.error_message,
@@ -463,16 +573,31 @@ class RecordingManager:
"plik_url": rec.plik_url,
}
@staticmethod
def _segment_files(pattern: str) -> list[str]:
glob_pattern = globmod.escape(pattern).replace("%03d", "*")
return sorted(
f for f in globmod.glob(glob_pattern)
if os.path.isfile(f) and not os.path.basename(f).startswith(".")
)
def _resolve_output_files(self, patterns: list[str], segment_mode: bool) -> list[str]:
files = []
for of in patterns:
if segment_mode:
files.extend(self._segment_files(of))
elif os.path.isfile(of) and os.path.getsize(of) > 0:
files.append(of)
return [f for f in files if not os.path.basename(f).startswith(".")]
def _calc_recording_size(self, output_files: list[str], segment_mode: bool) -> tuple[int, int]:
total_size = 0
segment_count = 0
for of in output_files:
if segment_mode:
pattern = of.replace('%03d', '*')
for f in globmod.glob(pattern):
if os.path.isfile(f):
total_size += os.path.getsize(f)
segment_count += 1
for f in self._segment_files(of):
total_size += os.path.getsize(f)
segment_count += 1
else:
if os.path.isfile(of):
total_size += os.path.getsize(of)
@@ -494,7 +619,7 @@ class RecordingManager:
for k, v in kwargs.items():
setattr(rec, k, v)
if kwargs.get("status") in ("stopped", "completed", "error"):
rec.stopped_at = datetime.datetime.utcnow()
rec.stopped_at = utcnow()
session.commit()
finally:
session.close()
@@ -503,9 +628,10 @@ class RecordingManager:
job_name: str, segment_mode: bool):
opts = self._rec_options.get(rec_id, {})
if opts.get("plik_enabled"):
patterns = list(self._output_files.get(rec_id, [output_file]))
files = self._resolve_output_files(patterns, segment_mode)
self._get_plik().upload_recording(
output_dir=output_dir,
output_file=output_file,
files=files,
job_name=job_name,
segment_mode=segment_mode,
delete_after=opts.get("delete_after_upload", False),
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
pytest==9.1.1
+6 -7
View File
@@ -1,8 +1,7 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
sqlalchemy==2.0.40
pydantic==2.11.3
apscheduler==3.11.0
pyyaml==6.0.2
python-multipart==0.0.20
fastapi==0.143.0
uvicorn[standard]==0.54.0
sqlalchemy==2.0.54
pydantic==2.14.0
apscheduler==3.11.3
pyyaml==6.0.3
httpx==0.28.1
+237 -112
View File
@@ -1,7 +1,13 @@
import datetime
import logging
import os
import re
from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import or_
from models import Job, Recording, get_session
from models import Job, Recording, get_session, prune_logs, utcnow
logger = logging.getLogger("scheduler")
DAY_MAP = {
"mo": 1, "mon": 1, "montag": 1,
@@ -13,6 +19,8 @@ DAY_MAP = {
"so": 7, "sun": 7, "sonntag": 7,
}
_TIME_RE = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
def _day_to_num(day: str) -> int:
return DAY_MAP.get(day.lower().strip(), 0)
@@ -27,6 +35,8 @@ def _matches_day(today_num: int, schedule: str) -> bool:
parts = entry.split("-", 1)
start = _day_to_num(parts[0])
end = _day_to_num(parts[1])
if not start or not end:
continue
if start <= end:
if start <= today_num <= end:
return True
@@ -39,7 +49,69 @@ def _matches_day(today_num: int, schedule: str) -> bool:
return False
def is_valid_time(t) -> bool:
return isinstance(t, str) and bool(_TIME_RE.match(t))
def is_valid_date(d) -> bool:
if not isinstance(d, str) or not re.match(r"^\d{4}-\d{2}-\d{2}$", d):
return False
try:
datetime.date.fromisoformat(d)
return True
except ValueError:
return False
def is_valid_days(days) -> bool:
if not isinstance(days, str):
return False
days = days.strip()
if days == "*":
return True
if not days:
return False
for entry in days.split(","):
entry = entry.strip()
parts = entry.split("-", 1) if "-" in entry else [entry]
if not all(_day_to_num(p) for p in parts):
return False
return True
def validate_schedule(job: dict):
"""Prüft Zeitplanfelder. Wirft ValueError mit deutscher Meldung."""
start = job.get("schedule_start") or ""
stop = job.get("schedule_stop") or ""
date = job.get("schedule_date") or ""
end_date = job.get("schedule_end_date") or ""
days = job.get("schedule_days")
days = "*" if days is None else days
if start and not is_valid_time(start):
raise ValueError("Startzeit muss im Format HH:MM angegeben werden")
if stop and not is_valid_time(stop):
raise ValueError("Stoppzeit muss im Format HH:MM angegeben werden")
if date and not is_valid_date(date):
raise ValueError("Datum muss im Format JJJJ-MM-TT angegeben werden")
if end_date and not is_valid_date(end_date):
raise ValueError("Enddatum muss im Format JJJJ-MM-TT angegeben werden")
if not is_valid_days(days):
raise ValueError("Ungültige Wochentage (erlaubt: * oder z.B. mo-fr,sa)")
if job.get("schedule_enabled") and not start:
raise ValueError("Für einen aktiven Zeitplan ist eine Startzeit erforderlich")
if job.get("schedule_once") and not date:
raise ValueError("Für eine einmalige Aufnahme ist ein Datum erforderlich")
if end_date:
if not date:
raise ValueError("Enddatum erfordert ein Startdatum")
if end_date < date:
raise ValueError("Enddatum darf nicht vor dem Startdatum liegen")
def _time_to_minutes(t: str) -> int:
if not is_valid_time(t):
raise ValueError(f"Ungültige Uhrzeit: {t!r}")
parts = t.split(":")
return int(parts[0]) * 60 + int(parts[1])
@@ -58,40 +130,78 @@ def _apply_buffers(job: dict) -> dict:
eff = dict(job)
pre = job.get("pre_buffer", 0) or 0
post = job.get("post_buffer", 0) or 0
if pre > 0 and eff.get("schedule_start"):
if pre > 0 and is_valid_time(eff.get("schedule_start")):
m = _time_to_minutes(eff["schedule_start"]) - pre
if m < 0:
m += 1440
m %= 1440
eff["schedule_start"] = f"{m // 60:02d}:{m % 60:02d}"
if post > 0 and eff.get("schedule_stop"):
if post > 0 and is_valid_time(eff.get("schedule_stop")):
m = _time_to_minutes(eff["schedule_stop"]) + post
if m >= 1440:
m -= 1440
m %= 1440
eff["schedule_stop"] = f"{m // 60:02d}:{m % 60:02d}"
return eff
def _is_multi_day(job: dict) -> bool:
return bool(job.get("schedule_once") and job.get("schedule_date") and job.get("schedule_end_date"))
def compute_duration(job: dict, apply_buffers: bool) -> int | None:
"""Aufnahmedauer in Sekunden aus max_duration bzw. Zeitplan. Wirft ValueError bei ungültigen Daten."""
duration = job.get("max_duration")
if duration:
return int(duration)
start = job.get("schedule_start") or ""
stop = job.get("schedule_stop") or ""
if not start or not stop:
return None
pre = (job.get("pre_buffer") or 0) if apply_buffers else 0
post = (job.get("post_buffer") or 0) if apply_buffers else 0
if _is_multi_day(job):
try:
start_dt = datetime.datetime.strptime(f"{job['schedule_date']} {start}", "%Y-%m-%d %H:%M")
end_dt = datetime.datetime.strptime(f"{job['schedule_end_date']} {stop}", "%Y-%m-%d %H:%M")
except (TypeError, ValueError):
raise ValueError("Ungültiges Datum oder Uhrzeit im Zeitplan")
start_dt -= datetime.timedelta(minutes=pre)
end_dt += datetime.timedelta(minutes=post)
return max(0, int((end_dt - start_dt).total_seconds()))
eff = _apply_buffers({**job, "pre_buffer": pre, "post_buffer": post})
start_min = _time_to_minutes(eff["schedule_start"])
stop_min = _time_to_minutes(eff["schedule_stop"])
if stop_min > start_min:
return (stop_min - start_min) * 60
return (1440 - start_min + stop_min) * 60
class StreamScheduler:
def __init__(self, recorder):
self.recorder = recorder
self._scheduler = BackgroundScheduler()
self._started_keys: set[str] = set()
self._last_errors: dict[int, str] = {}
def start(self):
self._scheduler.add_job(self._tick, "interval", seconds=30, id="scheduler_tick",
replace_existing=True, max_instances=1)
self._scheduler.add_job(self.recorder.check_scheduled_stops, "interval", seconds=10,
id="check_stops", replace_existing=True, max_instances=1)
self._scheduler.add_job(self.prune_logs, "interval", hours=24,
id="prune_logs", replace_existing=True, max_instances=1)
self._scheduler.start()
def stop(self):
self._scheduler.shutdown(wait=False)
def _occurrence_key(self, job: Job) -> str:
today = datetime.date.today().isoformat()
if job.schedule_once:
today = job.schedule_date or today
return f"{job.id}|{job.schedule_once}|{today}|{job.schedule_start}"
@staticmethod
def prune_logs():
try:
days = int(os.environ.get("LOG_RETENTION_DAYS", "90"))
except ValueError:
days = 90
return prune_logs(days)
def _original_occ_key(self, job: dict, now: datetime.datetime | None = None) -> str:
now = now or datetime.datetime.now()
@@ -103,7 +213,7 @@ class StreamScheduler:
now = now or datetime.datetime.now()
today_num = now.isoweekday()
today_date = now.strftime("%Y-%m-%d")
now_time = now.strftime("%H:%M")
now_min = now.hour * 60 + now.minute
occ_key = (
f"{job['id']}|{job['schedule_once']}|"
@@ -111,58 +221,58 @@ class StreamScheduler:
f"{job['schedule_start']}"
)
is_today = False
multi_day = bool(job.get("schedule_end_date") and job["schedule_once"])
multi_day = _is_multi_day(job)
state = {
"occ_key": occ_key,
"in_window": False,
"multi_day": multi_day,
"is_cross_midnight": False,
}
start = job.get("schedule_start") or ""
stop = job.get("schedule_stop") or ""
if not is_valid_time(start):
return state
if not is_valid_time(stop):
stop = ""
days = job.get("schedule_days") or "*"
is_today = False
if job["schedule_once"]:
if job["schedule_date"] == today_date:
is_today = True
else:
if _matches_day(today_num, job["schedule_days"] or "*"):
if _matches_day(today_num, days):
is_today = True
start_min = _time_to_minutes(start)
in_window = False
is_cross_midnight = False
if is_today:
if multi_day:
now_min = _time_to_minutes(now_time)
start_min = _time_to_minutes(job["schedule_start"])
if multi_day or not stop:
in_window = now_min >= start_min
elif job["schedule_stop"]:
start_min = _time_to_minutes(job["schedule_start"])
stop_min = _time_to_minutes(job["schedule_stop"])
else:
stop_min = _time_to_minutes(stop)
if stop_min <= start_min:
is_cross_midnight = True
now_min = _time_to_minutes(now_time)
in_window = now_min >= start_min
else:
in_window = _in_time_window(now_time, job["schedule_start"], job["schedule_stop"])
else:
now_min = _time_to_minutes(now_time)
start_min = _time_to_minutes(job["schedule_start"])
in_window = now_min >= start_min
elif job.get("schedule_stop"):
start_min = _time_to_minutes(job["schedule_start"])
stop_min = _time_to_minutes(job["schedule_stop"])
if stop_min <= start_min and stop_min > 0:
now_min = _time_to_minutes(now_time)
if now_min < stop_min:
yesterday = now - datetime.timedelta(days=1)
was_yesterday = False
if job["schedule_once"]:
was_yesterday = job["schedule_date"] == yesterday.strftime("%Y-%m-%d")
else:
was_yesterday = _matches_day(yesterday.isoweekday(), job["schedule_days"] or "*")
if was_yesterday:
is_cross_midnight = True
in_window = True
in_window = start_min <= now_min < stop_min
elif stop:
stop_min = _time_to_minutes(stop)
if stop_min <= start_min and stop_min > 0 and now_min < stop_min:
yesterday = now - datetime.timedelta(days=1)
if job["schedule_once"]:
was_yesterday = job["schedule_date"] == yesterday.strftime("%Y-%m-%d")
else:
was_yesterday = _matches_day(yesterday.isoweekday(), days)
if was_yesterday:
is_cross_midnight = True
in_window = True
return {
"occ_key": occ_key,
"in_window": in_window,
"multi_day": multi_day,
"is_cross_midnight": is_cross_midnight,
}
state["in_window"] = in_window
state["is_cross_midnight"] = is_cross_midnight
return state
def suppress_current_occurrence(self, job_id: int) -> bool:
session = get_session()
@@ -184,9 +294,12 @@ class StreamScheduler:
finally:
session.close()
occ_key = self._original_occ_key(job)
eff_job = _apply_buffers(job)
state = self._schedule_state(eff_job)
try:
occ_key = self._original_occ_key(job)
state = self._schedule_state(_apply_buffers(job))
except Exception as e:
logger.warning("Zeitplan von Job %s ungültig: %s", job_id, e)
return False
if not state["in_window"]:
return False
@@ -202,9 +315,7 @@ class StreamScheduler:
)
except (TypeError, ValueError):
return None
local_utc_offset = datetime.datetime.now() - datetime.datetime.utcnow()
return local_start - local_utc_offset
return local_start.astimezone(datetime.timezone.utc).replace(tzinfo=None)
def _has_recording_for_occurrence(self, job: dict, now: datetime.datetime) -> bool:
occurrence_start = self._occurrence_start_utc(job, now)
@@ -243,14 +354,23 @@ class StreamScheduler:
).first()
if not rec:
return None
if respect_stop and rec.scheduled_stop and rec.scheduled_stop > datetime.datetime.utcnow():
if respect_stop and rec.scheduled_stop and rec.scheduled_stop > utcnow():
return None
return rec.id
finally:
session.close()
def _prune_started_keys(self, now: datetime.datetime):
cutoff = (now - datetime.timedelta(days=2)).strftime("%Y-%m-%d")
for key in list(self._started_keys):
parts = key.split("|")
date = parts[2] if len(parts) >= 4 else ""
if not is_valid_date(date) or date < cutoff:
self._started_keys.discard(key)
def _tick(self):
now = datetime.datetime.now()
self._prune_started_keys(now)
session = get_session()
try:
@@ -274,63 +394,68 @@ class StreamScheduler:
finally:
session.close()
active_ids = set()
for job in job_list:
if not job["schedule_start"]:
continue
active_ids.add(job["id"])
try:
self._process_job(job, now)
self._last_errors.pop(job["id"], None)
except Exception as e:
err = f"{type(e).__name__}: {e}"
if self._last_errors.get(job["id"]) != err:
self._last_errors[job["id"]] = err
logger.error("Zeitplan-Fehler bei Job %s (%s): %s", job["id"], job.get("name"), err)
try:
self.recorder._log("ERROR", f"Zeitplan-Fehler: {e}", job.get("name"))
except Exception:
pass
occ_key = self._original_occ_key(job, now)
eff_job = _apply_buffers(job)
state = self._schedule_state(eff_job, now)
in_window = state["in_window"]
for job_id in list(self._last_errors):
if job_id not in active_ids:
self._last_errors.pop(job_id, None)
if in_window:
if (
occ_key not in self._started_keys
and not self._is_job_recording(job["id"])
and not self._has_recording_for_occurrence(job, now)
):
duration = job["max_duration"]
if not duration and eff_job["schedule_stop"]:
if state["multi_day"]:
start_dt = datetime.datetime.strptime(
f"{job['schedule_date']} {eff_job['schedule_start']}", "%Y-%m-%d %H:%M")
end_dt = datetime.datetime.strptime(
f"{job['schedule_end_date']} {eff_job['schedule_stop']}", "%Y-%m-%d %H:%M")
duration = max(0, int((end_dt - start_dt).total_seconds()))
else:
start_min = _time_to_minutes(eff_job["schedule_start"])
stop_min = _time_to_minutes(eff_job["schedule_stop"])
if stop_min > start_min:
duration = (stop_min - start_min) * 60
else:
duration = (1440 - start_min + stop_min) * 60
def _process_job(self, job: dict, now: datetime.datetime):
if not job["schedule_start"]:
return
self.recorder.start_recording(
job_id=job["id"],
name=job["name"],
stream_url=job["stream_url"],
stream_type=job["stream_type"],
output_format=job["output_format"] or "",
max_duration=duration,
extra_ffmpeg_args=job["extra_ffmpeg_args"] or "",
segment_duration=job["segment_duration"],
is_scheduled=True,
description=job.get("description", ""),
ntfy_enabled=job.get("ntfy_enabled", False),
plik_enabled=job.get("plik_enabled", False),
delete_after_upload=job.get("delete_after_upload", False),
)
self._started_keys.add(occ_key)
else:
if (
not state["multi_day"]
and not state["is_cross_midnight"]
and eff_job["schedule_stop"]
and self._is_job_recording(job["id"])
):
rec_id = self._get_active_scheduled_recording_for_job(
job["id"], respect_stop=True,
)
if rec_id:
self.recorder.stop_recording(rec_id)
self._started_keys.discard(occ_key)
occ_key = self._original_occ_key(job, now)
eff_job = _apply_buffers(job)
state = self._schedule_state(eff_job, now)
if state["in_window"]:
if (
occ_key not in self._started_keys
and not self._is_job_recording(job["id"])
and not self._has_recording_for_occurrence(job, now)
):
duration = compute_duration(job, apply_buffers=True)
# Erst merken, damit ein Startfehler nicht alle 30s erneut versucht wird
self._started_keys.add(occ_key)
self.recorder.start_recording(
job_id=job["id"],
name=job["name"],
stream_url=job["stream_url"],
stream_type=job["stream_type"],
output_format=job["output_format"] or "",
max_duration=duration,
extra_ffmpeg_args=job["extra_ffmpeg_args"] or "",
segment_duration=job["segment_duration"],
is_scheduled=True,
description=job.get("description", ""),
ntfy_enabled=job.get("ntfy_enabled", False),
plik_enabled=job.get("plik_enabled", False),
delete_after_upload=job.get("delete_after_upload", False),
)
else:
if (
not state["multi_day"]
and not state["is_cross_midnight"]
and eff_job["schedule_stop"]
and self._is_job_recording(job["id"])
):
rec_id = self._get_active_scheduled_recording_for_job(
job["id"], respect_stop=True,
)
if rec_id:
self.recorder.stop_recording(rec_id)
self._started_keys.discard(occ_key)
+54
View File
@@ -0,0 +1,54 @@
import base64
import os
import sys
import tempfile
import pytest
_TMP = tempfile.mkdtemp(prefix="stream-recorder-tests-")
os.environ["DATA_DIR"] = os.path.join(_TMP, "data")
os.environ["STATIC_DIR"] = os.path.join(_TMP, "no-static")
os.environ["RECORDING_ROOTS"] = os.path.join(_TMP, "recordings")
os.environ.pop("ENABLE_API_DOCS", None)
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import app as app_module # noqa: E402
from config import hash_password # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from models import get_session, Job, Station, Recording, LogEntry # noqa: E402
TEST_PASSWORD = "geheim-12345"
# Schneller Hash für Tests
TEST_HASH = hash_password(TEST_PASSWORD, iterations=1000)
def basic(user, password):
return "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
@pytest.fixture
def app_mod():
return app_module
@pytest.fixture
def client(app_mod):
cfg = app_mod.config
with cfg.lock:
cfg.data["auth"] = {"enabled": False, "username": "admin", "password_hash": TEST_HASH}
cfg.data["ntfy"] = {"url": "https://ntfy.example/topic", "token": "tk_secret", "events": "error"}
cfg.data["plik"] = {"url": "https://plik.example", "api_key": "plik_secret", "ttl": "30d"}
cfg.data["recording"]["download_path"] = os.environ["RECORDING_ROOTS"]
app_mod.clear_auth_cache()
app_mod.reset_auth_failures()
session = get_session()
try:
for model in (Job, Station, Recording, LogEntry):
session.query(model).delete()
session.commit()
finally:
session.close()
return TestClient(app_mod.app)
+282
View File
@@ -0,0 +1,282 @@
import datetime
import os
from conftest import TEST_PASSWORD, basic
from models import get_session, Job, Recording, LogEntry, utcnow
def _job(**kw):
data = {"name": "Testjob", "stream_url": "https://example.com/stream.mp3"}
data.update(kw)
return data
# --- Config ---
def test_config_get_never_leaks_secrets(client):
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
text = r.text
for secret in ("tk_secret", "plik_secret", "pbkdf2", TEST_PASSWORD):
assert secret not in text
assert body["auth"]["password_set"] is True
assert body["ntfy"]["token_set"] is True
assert body["plik"]["api_key_set"] is True
assert "password" not in body["auth"] and "password_hash" not in body["auth"]
assert "token" not in body["ntfy"] and "api_key" not in body["plik"]
assert set(body) == {"recording", "server", "auth", "ntfy", "plik", "schedule", "logging"}
def test_config_put_forbidden_download_path(client):
for path in ("/etc", "relative/path", os.environ["RECORDING_ROOTS"] + "/../escape"):
r = client.put("/api/config", json={"data": {"recording": {"download_path": path}}})
assert r.status_code == 400, path
def test_config_put_allowed_download_path(client, app_mod):
sub = os.path.join(os.environ["RECORDING_ROOTS"], "sub")
r = client.put("/api/config", json={"data": {"recording": {"download_path": sub}}})
assert r.status_code == 200, r.text
assert app_mod.config.get("recording", "download_path") == os.path.realpath(sub)
def test_config_put_rejects_server_and_unknown(client):
assert client.put("/api/config", json={"data": {"server": {"port": 1}}}).status_code == 422
assert client.put("/api/config", json={"data": {"auth": {"foo": 1}}}).status_code == 422
assert client.put("/api/config", json={"data": {"recording": {"max_retries": 5000}}}).status_code == 422
assert client.put("/api/config", json={"data": {"logging": {"level": "TRACE"}}}).status_code == 422
def test_config_put_semantic_validation(client):
assert client.put("/api/config", json={"data": {"ntfy": {"events": "start,foo"}}}).status_code == 400
assert client.put("/api/config", json={"data": {"ntfy": {"url": "file:///x"}}}).status_code == 400
assert client.put("/api/config", json={"data": {"plik": {"ttl": "30x"}}}).status_code == 400
assert client.put("/api/config", json={"data": {"plik": {"ttl": "12h"}}}).status_code == 200
def test_config_secrets_semantics(client, app_mod):
cfg = app_mod.config
# fehlt -> unverändert
assert client.put("/api/config", json={"data": {"ntfy": {"url": "https://n.example/t"}}}).status_code == 200
assert cfg.get("ntfy", "token") == "tk_secret"
# leer -> löschen
assert client.put("/api/config", json={"data": {"ntfy": {"token": ""}, "plik": {"api_key": ""}}}).status_code == 200
assert cfg.get("ntfy", "token") == "" and cfg.get("plik", "api_key") == ""
# Passwort leer -> 400, zu kurz -> 400
assert client.put("/api/config", json={"data": {"auth": {"password": ""}}}).status_code == 400
assert client.put("/api/config", json={"data": {"auth": {"password": "kurz"}}}).status_code == 400
old_hash = cfg.get("auth", "password_hash")
assert client.put("/api/config", json={"data": {"auth": {"password": "neues-passwort"}}}).status_code == 200
assert cfg.get("auth", "password_hash") != old_hash
assert "neues-passwort" not in open(cfg.config_path, encoding="utf-8").read()
def test_enable_auth_without_password(client, app_mod):
app_mod.config.data["auth"]["password_hash"] = ""
r = client.put("/api/config", json={"data": {"auth": {"enabled": True}}})
assert r.status_code == 400
r = client.put("/api/config", json={"data": {"auth": {"enabled": True, "password": "langes-passwort"}}})
assert r.status_code == 200
assert client.get("/api/config").status_code == 401
# --- Auth ---
def test_auth_middleware(client, app_mod):
app_mod.config.data["auth"]["enabled"] = True
assert client.get("/api/health").status_code == 200
assert client.get("/api/health/x").status_code == 401
r = client.get("/api/jobs")
assert r.status_code == 401
assert "Basic" in r.headers["www-authenticate"]
assert client.get("/api/jobs", headers={"Authorization": basic("admin", TEST_PASSWORD)}).status_code == 200
# zweiter Aufruf über Cache
assert client.get("/api/jobs", headers={"Authorization": basic("admin", TEST_PASSWORD)}).status_code == 200
assert client.get("/api/jobs", headers={"Authorization": "Basic !!!"}).status_code == 401
assert client.get("/api/jobs", headers={"Authorization": basic("root", TEST_PASSWORD)}).status_code == 401
def test_auth_bruteforce_429(client, app_mod):
app_mod.config.data["auth"]["enabled"] = True
for _ in range(10):
assert client.get("/api/jobs", headers={"Authorization": basic("admin", "falsch")}).status_code == 401
assert client.get("/api/jobs", headers={"Authorization": basic("admin", "falsch")}).status_code == 429
assert client.get("/api/jobs", headers={"Authorization": basic("admin", TEST_PASSWORD)}).status_code == 429
assert client.get("/api/health").status_code == 200
# --- CSRF / Header ---
def test_csrf(client):
body = _job()
assert client.post("/api/jobs", json=body, headers={"Sec-Fetch-Site": "cross-site"}).status_code == 403
assert client.post("/api/jobs", json=body, headers={"Origin": "https://evil.example"}).status_code == 403
assert client.post("/api/jobs", json=body, headers={"Origin": "null"}).status_code == 403
r = client.post("/api/jobs", json=body, headers={"Origin": "http://testserver", "Sec-Fetch-Site": "same-origin"})
assert r.status_code == 200, r.text
assert client.post("/api/jobs", json=body).status_code == 200
# GET bleibt unberührt
assert client.get("/api/jobs", headers={"Sec-Fetch-Site": "cross-site"}).status_code == 200
def test_security_headers_and_docs(client):
r = client.get("/api/health")
assert r.headers["x-content-type-options"] == "nosniff"
assert r.headers["x-frame-options"] == "DENY"
assert r.headers["referrer-policy"] == "no-referrer"
assert "frame-ancestors 'none'" in r.headers["content-security-policy"]
for path in ("/docs", "/redoc", "/openapi.json"):
assert client.get(path).status_code == 404
# --- Jobs ---
def test_job_validation(client):
assert client.post("/api/jobs", json=_job(stream_url="file:///etc/passwd")).status_code == 400
assert client.post("/api/jobs", json=_job(stream_url="concat:a|b")).status_code == 400
r = client.post("/api/jobs", json=_job(extra_ffmpeg_args="-c:a copy /app/app.py"))
assert r.status_code == 400 and "/app/app.py" in r.json()["detail"]
assert client.post("/api/jobs", json=_job(schedule_enabled=True)).status_code == 400
assert client.post("/api/jobs", json=_job(schedule_start="24:00")).status_code == 400
assert client.post("/api/jobs", json=_job(station_id=9999)).status_code == 400
assert client.post("/api/jobs", json=_job(stream_type="evil")).status_code == 422
assert client.post("/api/jobs", json=_job(name=" ")).status_code == 422
assert client.post("/api/jobs", json=_job(segment_duration=5)).status_code == 422
assert client.post("/api/jobs", json=_job(pre_buffer=500)).status_code == 422
r = client.post("/api/jobs", json=_job(segment_duration=0, schedule_enabled=True, schedule_start="10:00",
schedule_days="mo-fr", extra_ffmpeg_args="-c:a libmp3lame -b:a 128k"))
assert r.status_code == 200, r.text
job = client.get(f"/api/jobs/{r.json()['id']}").json()
assert job["segment_duration"] is None
def test_timestamps_are_utc_aware(client):
job_id = client.post("/api/jobs", json=_job()).json()["id"]
station_id = client.post("/api/stations", json={"name": "S", "stream_url": "https://e.example/s"}).json()["id"]
session = get_session()
try:
session.add(Recording(job_id=None, job_name="x", stream_url="https://e.example", status="completed",
stopped_at=utcnow()))
session.add(LogEntry(level="INFO", message="m"))
session.commit()
finally:
session.close()
stamps = [
client.get(f"/api/jobs/{job_id}").json()["created_at"],
client.get("/api/jobs").json()[0]["created_at"],
client.get(f"/api/stations/{station_id}").json()["created_at"],
client.get("/api/recordings").json()[0]["started_at"],
client.get("/api/recordings").json()[0]["stopped_at"],
client.get("/api/logs").json()[0]["timestamp"],
]
for s in stamps:
assert s.endswith("+00:00"), s
assert abs(datetime.datetime.fromisoformat(s) - datetime.datetime.now(datetime.timezone.utc)).total_seconds() < 120
def test_delete_station_unlinks_jobs(client):
sid = client.post("/api/stations", json={"name": "S", "stream_url": "https://e.example/s"}).json()["id"]
jid = client.post("/api/jobs", json=_job(station_id=sid)).json()["id"]
assert client.delete(f"/api/stations/{sid}").status_code == 200
assert client.get(f"/api/jobs/{jid}").json()["station_id"] is None
def _add_recording(**kw):
session = get_session()
try:
rec = Recording(job_name="x", stream_url="https://e.example", **kw)
session.add(rec)
session.commit()
return rec.id
finally:
session.close()
def test_delete_and_start_job_while_recording(client, app_mod, monkeypatch):
jid = client.post("/api/jobs", json=_job()).json()["id"]
_add_recording(job_id=jid, status="running")
assert client.delete(f"/api/jobs/{jid}").status_code == 400
called = []
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: called.append(kw) or {})
assert client.post(f"/api/jobs/{jid}/record").status_code == 409
assert not called
def test_start_job_legacy_bad_data(client, app_mod, monkeypatch):
session = get_session()
try:
job = Job(name="alt", stream_url="https://e.example/s", schedule_start="10:00", schedule_stop="xx")
session.add(job)
session.commit()
jid = job.id
finally:
session.close()
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: {"recording_id": 1})
assert client.post(f"/api/jobs/{jid}/record").status_code == 400
def test_start_job_passes_duration(client, app_mod, monkeypatch):
jid = client.post("/api/jobs", json=_job(schedule_start="10:00", schedule_stop="11:30",
pre_buffer=5)).json()["id"]
calls = []
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: calls.append(kw) or {"recording_id": 1})
assert client.post(f"/api/jobs/{jid}/record").status_code == 200
assert calls[0]["max_duration"] == 5400
def test_quick_record_validation(client, app_mod, monkeypatch):
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: {"recording_id": 1})
assert client.post("/api/quick-record", json={"url": "file:///etc/passwd"}).status_code == 400
assert client.post("/api/quick-record", json={"url": "https://e.example/s", "max_duration": 0}).status_code == 422
assert client.post("/api/quick-record", json={"url": "https://e.example/s"}).status_code == 200
# --- Recordings ---
def test_extend(client):
unlimited = _add_recording(status="running")
r = client.post(f"/api/recordings/{unlimited}/extend?minutes=10")
assert r.status_code == 400
assert r.json()["detail"] == "Aufnahme ist bereits unbegrenzt"
stop = utcnow() + datetime.timedelta(minutes=5)
limited = _add_recording(status="running", scheduled_stop=stop)
assert client.post(f"/api/recordings/{limited}/extend?minutes=10").status_code == 200
rec = client.get(f"/api/recordings/{limited}").json()
new_stop = datetime.datetime.fromisoformat(rec["scheduled_stop"]).replace(tzinfo=None)
assert abs((new_stop - stop).total_seconds() - 600) < 1
assert client.post(f"/api/recordings/{limited}/extend?minutes=0").status_code == 422
assert client.post(f"/api/recordings/{limited}/extend?minutes=1441").status_code == 422
assert client.post("/api/recordings/99999/extend?minutes=5").status_code == 404
def test_stop_endpoint(client):
assert client.post("/api/recordings/99999/stop").status_code == 404
done = _add_recording(status="completed")
assert client.post(f"/api/recordings/{done}/stop").status_code == 400
def test_query_limits(client):
assert client.get("/api/logs?limit=0").status_code == 422
assert client.get("/api/logs?limit=1001").status_code == 422
assert client.get("/api/logs?level=bogus").status_code == 400
assert client.get("/api/logs?level=warn").status_code == 200
assert client.get("/api/recordings?limit=501").status_code == 422
def test_cleanup_stale(client, app_mod):
rid = _add_recording(status="running", pid=999999, output_file="/nonexistent/x.mp3")
rid2 = _add_recording(status="starting")
app_mod.recorder.cleanup_stale()
session = get_session()
try:
for i in (rid, rid2):
rec = session.get(Recording, i)
assert rec.status == "error"
assert rec.error_message == "Abgebrochen durch Neustart"
assert rec.stopped_at is not None
finally:
session.close()
@@ -0,0 +1,92 @@
import datetime
import os
from unittest import mock
import yaml
import config as config_module
from config import Config, verify_password
from models import get_session, Job
from scheduler import StreamScheduler
def _add_job(**kw):
session = get_session()
try:
job = Job(name=kw.pop("name", "j"), stream_url="https://e.example/s", schedule_enabled=True, **kw)
session.add(job)
session.commit()
return job.id
finally:
session.close()
def test_bad_legacy_job_does_not_break_tick(client):
_add_job(name="kaputt1", schedule_start="00:00", schedule_stop="xx")
_add_job(name="kaputt2", schedule_start="25:99", schedule_days="foo-bar")
_add_job(name="kaputt3", schedule_start="00:00", schedule_once=True, schedule_date="kein-datum")
good = _add_job(name="gut", schedule_start="00:00", schedule_stop="", schedule_days="*")
rec = mock.MagicMock()
sched = StreamScheduler(rec)
with mock.patch("scheduler.logger") as log:
sched._tick()
sched._tick()
started = [c.kwargs["job_id"] for c in rec.start_recording.call_args_list]
assert started == [good]
# Fehler nur einmal pro Job geloggt, nicht bei jedem Tick
assert log.error.call_count == 1
def test_started_keys_pruned(client):
sched = StreamScheduler(mock.MagicMock())
old = (datetime.date.today() - datetime.timedelta(days=5)).isoformat()
today = datetime.date.today().isoformat()
sched._started_keys = {f"1|False|{old}|10:00", f"2|False|{today}|10:00", "kaputt"}
sched._prune_started_keys(datetime.datetime.now())
assert sched._started_keys == {f"2|False|{today}|10:00"}
def test_first_start_generates_password(tmp_path, monkeypatch):
monkeypatch.setattr(config_module, "PBKDF2_ITERATIONS", 1000)
path = tmp_path / "config.yml"
cfg = Config(str(path))
assert cfg.generated_password
data = yaml.safe_load(path.read_text(encoding="utf-8"))
assert data["auth"]["enabled"] is True
assert "password" not in data["auth"]
assert verify_password(cfg.generated_password, data["auth"]["password_hash"])
assert cfg.generated_password not in path.read_text(encoding="utf-8")
if os.name == "posix":
assert (os.stat(path).st_mode & 0o777) == 0o600
# Zweiter Start: kein neues Passwort
assert Config(str(path)).generated_password is None
def test_plaintext_password_migration(tmp_path):
path = tmp_path / "config.yml"
path.write_text("auth:\n enabled: false\n username: admin\n password: altes-passwort\n", encoding="utf-8")
cfg = Config(str(path))
assert cfg.generated_password is None
data = yaml.safe_load(path.read_text(encoding="utf-8"))
assert "password" not in data["auth"]
assert data["auth"]["enabled"] is False
assert verify_password("altes-passwort", data["auth"]["password_hash"])
def test_legacy_default_password_counts_as_unset(tmp_path):
path = tmp_path / "config.yml"
path.write_text("auth:\n enabled: false\n username: admin\n password: stream-recorder\n", encoding="utf-8")
Config(str(path))
data = yaml.safe_load(path.read_text(encoding="utf-8"))
assert data["auth"]["password_hash"] == ""
def test_legacy_default_password_kept_when_auth_enabled(tmp_path):
path = tmp_path / "config.yml"
path.write_text("auth:\n enabled: true\n username: admin\n password: stream-recorder\n", encoding="utf-8")
cfg = Config(str(path))
assert cfg.default_password_active is True
assert verify_password("stream-recorder", cfg.get("auth", "password_hash"))
+118
View File
@@ -0,0 +1,118 @@
import pytest
from recorder import RecordingManager, validate_ffmpeg_args, validate_stream_url, PROTOCOL_WHITELIST
from scheduler import compute_duration, validate_schedule
@pytest.mark.parametrize("args", [
"",
"-c:a libmp3lame -b:a 128k",
"-metadata title=Foo/Bar",
"-metadata 'title=Mit Leerzeichen'",
"-vn -ar 44100 -ac 2",
"-af volume=2",
])
def test_ffmpeg_args_allowed(args):
validate_ffmpeg_args(args)
@pytest.mark.parametrize("args", [
"/app/app.py",
"-i x",
"-af amovie=/etc/passwd",
"-filter:a AMOVIE=x",
"-af sendcmd=f=x",
"-c:a",
"-c:a -b:a",
"-b:a 128k out.mp3",
"-y",
"-f segment -segment_list /tmp/x",
"-metadata ''",
"'unclosed",
"-vn " * 200,
])
def test_ffmpeg_args_rejected(args):
with pytest.raises(ValueError):
validate_ffmpeg_args(args)
def test_ffmpeg_args_error_names_token():
with pytest.raises(ValueError, match="/app/app.py"):
validate_ffmpeg_args("-vn /app/app.py")
@pytest.mark.parametrize("url", [
"https://example.com/stream.mp3",
"http://radio.example:8000/live",
"rtmp://live.example/app/key",
"rtsps://cam.example/stream",
])
def test_url_allowed(url):
assert validate_stream_url(url) == url
@pytest.mark.parametrize("url", [
"file:///etc/passwd",
"concat:/etc/passwd|/etc/shadow",
"subfile:,,start,0,end,0,,:/etc/passwd",
"https://",
"http://exa mple.com/",
"https://example.com/\nfoo",
"ftp://example.com/x",
"",
"https://example.com/" + "a" * 1000,
])
def test_url_rejected(url):
with pytest.raises(ValueError):
validate_stream_url(url)
def test_ffmpeg_cmd_has_protocol_whitelist_before_input():
mgr = RecordingManager(config=None)
cmd = mgr._build_ffmpeg_cmd("https://example.com/a.m3u8", "hls", "/x/out.mp4",
"-metadata title=X", False, None)
assert cmd.index("-protocol_whitelist") < cmd.index("-i")
assert cmd[cmd.index("-protocol_whitelist") + 1] == PROTOCOL_WHITELIST
assert "file" not in PROTOCOL_WHITELIST.split(",")
assert cmd[-1] == "/x/out.mp4"
assert cmd[-3:-1] == ["-metadata", "title=X"]
def test_ffmpeg_cmd_revalidates():
mgr = RecordingManager(config=None)
with pytest.raises(ValueError):
mgr._build_ffmpeg_cmd("https://example.com/a", "http", "/x/out.mp4", "/app/app.py", False, None)
with pytest.raises(ValueError):
mgr._build_ffmpeg_cmd("file:///etc/passwd", "http", "/x/out.mp4", "", False, None)
@pytest.mark.parametrize("job,msg", [
({"schedule_start": "25:00"}, "HH:MM"),
({"schedule_start": "10:00", "schedule_stop": "9:5"}, "HH:MM"),
({"schedule_date": "2026-02-30"}, "JJJJ"),
({"schedule_days": "mo-xx"}, "Wochentage"),
({"schedule_enabled": True}, "Startzeit"),
({"schedule_once": True, "schedule_start": "10:00"}, "Datum"),
({"schedule_date": "2026-05-02", "schedule_end_date": "2026-05-01"}, "Enddatum"),
])
def test_schedule_invalid(job, msg):
with pytest.raises(ValueError, match=msg):
validate_schedule(job)
def test_schedule_valid():
validate_schedule({"schedule_enabled": True, "schedule_start": "23:30", "schedule_stop": "01:00",
"schedule_days": "Mo-Fr,SA"})
validate_schedule({"schedule_once": True, "schedule_date": "2026-05-01",
"schedule_end_date": "2026-05-03", "schedule_start": "10:00"})
def test_compute_duration():
assert compute_duration({"max_duration": 120}, apply_buffers=True) == 120
assert compute_duration({"schedule_start": "10:00", "schedule_stop": "11:00"}, False) == 3600
assert compute_duration({"schedule_start": "23:00", "schedule_stop": "01:00"}, False) == 7200
assert compute_duration({"schedule_start": "10:00", "schedule_stop": "11:00",
"pre_buffer": 5, "post_buffer": 5}, True) == 4200
assert compute_duration({"schedule_start": "10:00", "schedule_stop": ""}, True) is None
with pytest.raises(ValueError):
compute_duration({"schedule_start": "10:00", "schedule_stop": "xx"}, False)