Release: v3.2.0
Build Release Docker Image / build-arm64 (release) Successful in 2m11s
Build Release Docker Image / build-amd64 (release) Successful in 2m24s
Build Release Docker Image / publish-release-manifest (release) Successful in 6s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Asmus
2026-10-11 22:19:20 +02:00
co-authored by Claude Opus 4.6
25 changed files with 2577 additions and 686 deletions
+21
View File
@@ -0,0 +1,21 @@
# Nur bin/backend, bin/frontend, bin/config.yml.dist und
# bin/docker-entrypoint.sh werden ins Image kopiert.
.git
.gitea
.gitignore
.gitattributes
.dockerignore
.vscode
.idea
.venv
venv
data
docs
*.md
LICENSE
docker-compose*.yml
**/__pycache__
**/*.pyc
**/*.pyo
**/.pytest_cache
bin/backend/tests
+20 -3
View File
@@ -1,29 +1,46 @@
FROM python:3.12-slim FROM python:3.12-slim
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg curl && \ apt-get install -y --no-install-recommends ffmpeg curl && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# Unprivilegierter Laufzeit-User (UID/GID per PUID/PGID zur Laufzeit anpassbar)
RUN groupadd -g 1000 app && \
useradd -u 1000 -g app -M -d /nonexistent -s /usr/sbin/nologin app
WORKDIR /app WORKDIR /app
COPY bin/backend/requirements.txt . COPY bin/backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install -r requirements.txt
# Code bleibt root-gehörig und für den app-User schreibgeschützt
COPY bin/backend/ /app/ COPY bin/backend/ /app/
COPY bin/frontend/ /app/frontend/ COPY bin/frontend/ /app/frontend/
COPY bin/config.yml.dist /app/config.yml.dist COPY bin/config.yml.dist /app/config.yml.dist
COPY bin/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN mkdir -p /app/data/recordings RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \
chmod 755 /usr/local/bin/docker-entrypoint.sh && \
mkdir -p /app/data/recordings && \
chown -R app:app /app/data
ARG APP_VERSION=dev ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION} ENV APP_VERSION=${APP_VERSION}
ENV DATA_DIR=/app/data ENV DATA_DIR=/app/data
ENV STATIC_DIR=/app/frontend/static ENV STATIC_DIR=/app/frontend/static
ENV PYTHONUNBUFFERED=1 ENV PUID=1000 \
PGID=1000 \
RECORDING_ROOTS=/app/data/recordings:/recordings
EXPOSE 8484 EXPOSE 8484
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8484/api/health || exit 1 CMD curl -f http://localhost:8484/api/health || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["python", "app.py"] CMD ["python", "app.py"]
+51 -2
View File
@@ -51,6 +51,14 @@ docker compose up -d
Das Web-UI ist unter `http://localhost:8484` erreichbar. Die Konfiguration wird beim ersten Start automatisch in `data/config.yml` erstellt. Das Web-UI ist unter `http://localhost:8484` erreichbar. Die Konfiguration wird beim ersten Start automatisch in `data/config.yml` erstellt.
Beim ersten Start ist die Anmeldung aktiv: Benutzer `admin`, das zufällig erzeugte Passwort steht einmalig im Container-Log:
```bash
docker logs stream-recorder
```
Das Passwort kann danach unter **Einstellungen** geändert werden.
## Container Image ## Container Image
Release-Images werden automatisch in der Gitea Container Registry veröffentlicht: Release-Images werden automatisch in der Gitea Container Registry veröffentlicht:
@@ -75,23 +83,64 @@ services:
container_name: stream-recorder container_name: stream-recorder
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8484:8484" - "127.0.0.1:8484:8484"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
environment: environment:
TZ: Europe/Berlin TZ: Europe/Berlin
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETUID
- SETGID
``` ```
Der Port ist standardmäßig nur auf `127.0.0.1` gebunden. Für Zugriff von außen einen Reverse-Proxy (mit TLS) davorschalten oder gezielt an eine IP binden, z.B. eine Tailscale-IP (`"100.64.0.10:8484:8484"`).
### Umgebungsvariablen
| Variable | Standard | Beschreibung |
|----------|----------|-------------|
| `TZ` | — | Zeitzone (z.B. `Europe/Berlin`) |
| `PUID` / `PGID` | `1000` | UID/GID, unter der die Anwendung im Container läuft |
| `RECORDING_ROOTS` | `/app/data/recordings:/recordings` | Erlaubte Basisverzeichnisse für `download_path` (durch `:` getrennt) |
| `LOG_RETENTION_DAYS` | `90` | Log-Einträge älter als X Tage werden gelöscht |
| `ENABLE_API_DOCS` | — | `1` aktiviert die Swagger-UI unter `/docs` |
## Daten ## Daten
Alle persistenten Daten liegen in einem einzigen Volume (`./data`): Alle persistenten Daten liegen in einem einzigen Volume (`./data`):
| Pfad | Beschreibung | | Pfad | Beschreibung |
|------|-------------| |------|-------------|
| `data/config.yml` | Konfiguration (wird beim ersten Start automatisch erstellt) | | `data/config.yml` | Konfiguration (wird beim ersten Start automatisch erstellt, Rechte `600`) |
| `data/stream-recorder.db` | SQLite-Datenbank | | `data/stream-recorder.db` | SQLite-Datenbank |
| `data/recordings/` | Aufnahmen (pro Job ein Unterordner) | | `data/recordings/` | Aufnahmen (pro Job ein Unterordner) |
## Sicherheit
- **Anmeldung** — Basic Auth ist bei Neuinstallationen aktiv. Passwörter werden als PBKDF2-Hash gespeichert. Nach 10 fehlgeschlagenen Logins innerhalb von 5 Minuten wird die IP für den Rest des Zeitfensters gesperrt (HTTP 429).
- **Secrets** — Passwort, NTFY-Token und Plik-API-Key werden von der API nie zurückgegeben.
- **Kein Root** — Der Container startet als root, passt die Besitzrechte von `data/` an und startet die Anwendung dann als `PUID`/`PGID` (Standard `1000`). Der Anwendungscode ist für diesen User schreibgeschützt.
- **Eingaben** — Stream-URLs nur per `http(s)`, `rtmp(s)` und `rtsp(s)`; ffmpeg läuft mit Protokoll-Whitelist. Extra ffmpeg-Argumente unterliegen einer Allowlist (siehe [Jobs](docs/jobs.md#extra-ffmpeg-argumente)). `download_path` muss innerhalb von `RECORDING_ROOTS` liegen.
- **Web** — Kein CORS; schreibende Cross-Site-Requests werden abgewiesen (403). Security-Header und CSP sind gesetzt. Die Swagger-UI (`/docs`) ist standardmäßig deaktiviert.
- **Netzwerk** — Den Port nicht ungeschützt ins Internet stellen; Reverse-Proxy mit TLS oder VPN verwenden.
## Upgrade bestehender Installationen
- **Besitzrechte** — Beim Start werden `data/` und die Verzeichnisse aus `RECORDING_ROOTS` automatisch auf `PUID:PGID` umgestellt (nur Dateien mit abweichendem Besitzer). Soll ein anderer Host-User Besitzer sein, `PUID`/`PGID` setzen.
- **Anmeldung** — Die bisherige Auth-Einstellung bleibt erhalten. Ein Klartext-Passwort in `config.yml` wird beim Start automatisch in einen Hash umgewandelt.
- **Secrets rotieren** — Ältere Versionen haben NTFY-Token und Plik-API-Key über `/api/config` ausgeliefert. War die Instanz für andere erreichbar, diese Secrets erneuern.
- **Port-Bindung** — Die mitgelieferte `docker-compose.yml` bindet nur noch an `127.0.0.1`. Bei Bedarf anpassen (siehe oben).
- **download_path** — Liegt ein eigener Aufnahmepfad außerhalb von `/app/data/recordings` und `/recordings`, muss er in `RECORDING_ROOTS` aufgenommen werden.
- **Extra ffmpeg-Argumente** — Bestehende Jobs mit nicht erlaubten Argumenten müssen angepasst werden.
- **Laufende Aufnahmen** — Aufnahmen, die beim Neustart liefen, werden als Fehler „Abgebrochen durch Neustart" markiert.
## Multi-Arch Build ## Multi-Arch Build
```bash ```bash
+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 os
import secrets
import tempfile
import threading
import yaml import yaml
DEFAULT_DOWNLOAD_PATH = "/app/data/recordings"
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"recording": { "recording": {
"download_path": "/app/data/recordings", "download_path": DEFAULT_DOWNLOAD_PATH,
"max_retries": 5, "max_retries": 5,
"retry_delay": 5, "retry_delay": 5,
}, },
@@ -14,7 +23,7 @@ DEFAULT_CONFIG = {
"auth": { "auth": {
"enabled": False, "enabled": False,
"username": "admin", "username": "admin",
"password": "stream-recorder", "password_hash": "",
}, },
"ntfy": { "ntfy": {
"url": "", "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: class Config:
def __init__(self, config_path: str = "/app/data/config.yml"): def __init__(self, config_path: str = "/app/data/config.yml"):
self.config_path = config_path self.config_path = config_path
self.data = {} self.data = {}
self.lock = threading.RLock()
self.generated_password = None
self.password_migrated = False
self.default_password_active = False
self.load() self.load()
def load(self): def load(self):
self.data = _deep_copy(DEFAULT_CONFIG) with self.lock:
if os.path.exists(self.config_path): self.data = _deep_copy(DEFAULT_CONFIG)
with open(self.config_path, "r") as f: if os.path.exists(self.config_path):
user_config = yaml.safe_load(f) or {} with open(self.config_path, "r", encoding="utf-8") as f:
_deep_merge(self.data, user_config) user_config = yaml.safe_load(f) or {}
else: _deep_merge(self.data, user_config)
self.save() 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): def save(self):
os.makedirs(os.path.dirname(self.config_path), exist_ok=True) with self.lock:
with open(self.config_path, "w") as f: directory = os.path.dirname(self.config_path) or "."
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True, sort_keys=False) 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): def get(self, *keys, default=None):
val = self.data with self.lock:
for key in keys: val = self.data
if isinstance(val, dict) and key in val: for key in keys:
val = val[key] if isinstance(val, dict) and key in val:
else: val = val[key]
return default else:
return val return default
return val
def _deep_copy(d): def _deep_copy(d):
+56 -8
View File
@@ -1,9 +1,25 @@
import datetime 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 from sqlalchemy.orm import declarative_base, sessionmaker
Base = declarative_base() 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): class Job(Base):
__tablename__ = "jobs" __tablename__ = "jobs"
@@ -37,8 +53,8 @@ class Job(Base):
station_id = Column(Integer, nullable=True) station_id = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow) created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
class Station(Base): class Station(Base):
@@ -49,8 +65,8 @@ class Station(Base):
stream_url = Column(String(1000), nullable=False) stream_url = Column(String(1000), nullable=False)
stream_type = Column(String(20), default="auto") stream_type = Column(String(20), default="auto")
description = Column(Text, default="") description = Column(Text, default="")
created_at = Column(DateTime, default=datetime.datetime.utcnow) created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
class Recording(Base): class Recording(Base):
@@ -65,7 +81,7 @@ class Recording(Base):
output_file = Column(String(500), default="") output_file = Column(String(500), default="")
pid = Column(Integer, nullable=True) pid = Column(Integer, nullable=True)
status = Column(String(20), default="running") # running, stopped, completed, error 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) stopped_at = Column(DateTime, nullable=True)
scheduled_stop = Column(DateTime, nullable=True) scheduled_stop = Column(DateTime, nullable=True)
segment_mode = Column(Boolean, default=False) segment_mode = Column(Boolean, default=False)
@@ -79,7 +95,7 @@ class LogEntry(Base):
__tablename__ = "logs" __tablename__ = "logs"
id = Column(Integer, primary_key=True, autoincrement=True) 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") level = Column(String(10), default="INFO")
message = Column(Text, nullable=False) message = Column(Text, nullable=False)
job_name = Column(String(200), nullable=True) job_name = Column(String(200), nullable=True)
@@ -91,7 +107,20 @@ _SessionLocal = None
def init_db(db_path: str): def init_db(db_path: str):
global _engine, _SessionLocal 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) Base.metadata.create_all(_engine)
_SessionLocal = sessionmaker(bind=_engine) _SessionLocal = sessionmaker(bind=_engine)
_migrate(_engine) _migrate(_engine)
@@ -115,3 +144,22 @@ def _migrate(engine):
def get_session(): def get_session():
return _SessionLocal() 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.""" """NTFY push notification plugin."""
import subprocess import logging
import threading import threading
import httpx
logger = logging.getLogger("plugins.ntfy")
EVENT_TAGS = { EVENT_TAGS = {
"start": "red_circle", "start": "red_circle",
"stop": "white_check_mark", "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( def send_notification(
url: str, url: str,
title: str, title: str,
@@ -21,26 +42,11 @@ def send_notification(
): ):
if not url: if not url:
return return
threading.Thread(
cmd = [ target=_post,
"curl", "-s", "-o", "/dev/null", "--max-time", "10", args=(url, title, message, token, priority, tags),
"-H", f"Title: {title}", daemon=True,
"-H", f"Priority: {priority}", ).start()
]
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()
class NtfyNotifier: class NtfyNotifier:
@@ -53,7 +59,7 @@ class NtfyNotifier:
url = self.config.get("ntfy", "url", default="") url = self.config.get("ntfy", "url", default="")
if not url: if not url:
return False 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(",")] 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"): 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 json
import os import os
import re import re
import subprocess
import threading import threading
from typing import Callable from typing import Callable
import httpx
UPLOAD_TIMEOUT = httpx.Timeout(7200.0, connect=30.0)
def _parse_ttl_seconds(ttl: str) -> int | None: def _parse_ttl_seconds(ttl: str) -> int | None:
if not ttl: if not ttl:
@@ -23,6 +26,41 @@ def _parse_ttl_seconds(ttl: str) -> int | None:
return 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( def upload_file(
file_path: str, file_path: str,
plik_url: str, plik_url: str,
@@ -40,58 +78,28 @@ def upload_file(
plik_base = plik_url.rstrip("/") plik_base = plik_url.rstrip("/")
filename = os.path.basename(file_path) filename = os.path.basename(file_path)
cmd = ["curl", "-sS", "--fail-with-body", "--max-time", "7200"] headers = {}
if api_key: if api_key:
cmd += ["-H", f"X-PlikToken: {api_key}"] headers["X-PlikToken"] = api_key
cmd += ["-F", f"file=@{file_path};filename={filename}"]
form = {}
ttl_secs = _parse_ttl_seconds(ttl) ttl_secs = _parse_ttl_seconds(ttl)
if ttl_secs is not None: if ttl_secs is not None:
cmd += ["-F", f"ttl={ttl_secs}"] form["ttl"] = str(ttl_secs)
cmd.append(plik_base)
try: try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=7200, stdin=subprocess.DEVNULL) with open(file_path, "rb") as fh:
if result.returncode != 0: resp = httpx.post(
err = result.stderr.strip() or result.stdout.strip() or f"curl exit code {result.returncode}" plik_base,
return {"error": f"curl-Fehler: {err[:500]}"} headers=headers,
data=form,
try: files={"file": (filename, fh, "application/octet-stream")},
data = json.loads(result.stdout) timeout=UPLOAD_TIMEOUT,
)
if "error" in data: if resp.status_code >= 400:
return {"error": f"Plik-Server: {data['error']}"} return {"error": f"HTTP {resp.status_code}: {resp.text.strip()[:500]}"}
return _parse_response(resp.text, plik_base, filename)
upload_id = data.get("id", "") except httpx.TimeoutException:
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:
return {"error": "Upload-Timeout (max. 2h)"} return {"error": "Upload-Timeout (max. 2h)"}
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
@@ -103,9 +111,8 @@ class PlikUploader:
self._log = log_fn self._log = log_fn
self._notify = notify_fn self._notify = notify_fn
def upload_recording(self, output_dir: str, output_file: str, job_name: str, def upload_recording(self, files: list[str], job_name: str, segment_mode: bool,
segment_mode: bool, delete_after: bool, ntfy_enabled: bool, delete_after: bool, ntfy_enabled: bool, save_url_fn: Callable = None):
save_url_fn: Callable = None):
plik_url = self.config.get("plik", "url", default="") plik_url = self.config.get("plik", "url", default="")
if not plik_url: if not plik_url:
self._log("WARN", f"Plik-Upload übersprungen: Keine URL konfiguriert [{job_name}]", job_name) 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="") api_key = self.config.get("plik", "api_key", default="")
ttl = self.config.get("plik", "ttl", default="30d") ttl = self.config.get("plik", "ttl", default="30d")
files = [f for f in files if not os.path.basename(f).startswith(".")]
def _do_upload(): def _do_upload():
try: try:
if segment_mode: self._upload_files(files, job_name, segment_mode, plik_url, api_key, ttl,
self._upload_segments(output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn) 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)
except Exception as e: except Exception as e:
self._log("ERROR", f"Plik-Upload Fehler: {e} [{job_name}]", job_name) self._log("ERROR", f"Plik-Upload Fehler: {e} [{job_name}]", job_name)
threading.Thread(target=_do_upload, daemon=True).start() 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): def _remove(self, path: str, job_name: str):
if not os.path.isfile(file_path) or os.path.getsize(file_path) == 0: 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) self._log("WARN", f"Plik-Upload übersprungen: Datei fehlt oder leer [{job_name}]", job_name)
return return
self._log("INFO", f"Plik-Upload: {os.path.basename(file_path)} [{job_name}]", job_name) label = "Segment" if segment_mode else "Teil"
result = upload_file(file_path, plik_url, api_key, ttl) multi = len(files) > 1
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
uploaded = 0 uploaded = 0
last_browser_url = None last_browser_url = None
for fname in files: for fpath in files:
fpath = os.path.join(output_dir, fname) fname = os.path.basename(fpath)
if os.path.getsize(fpath) == 0: prefix = f"Plik {label}-Upload" if multi else "Plik-Upload"
continue self._log("INFO", f"{prefix}: {fname} [{job_name}]", job_name)
self._log("INFO", f"Plik Segment-Upload: {fname} [{job_name}]", job_name)
result = upload_file(fpath, plik_url, api_key, ttl) result = upload_file(fpath, plik_url, api_key, ttl)
if result and "error" in result: 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"): elif result and result.get("browser_url"):
last_browser_url = result["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 uploaded += 1
if delete_after: if delete_after:
os.remove(fpath) self._remove(fpath, job_name)
self._log("INFO", f"Segment gelöscht: {fname} [{job_name}]", job_name)
else: 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 uploaded > 0:
if save_url_fn and last_browser_url: if save_url_fn and last_browser_url:
save_url_fn(last_browser_url) save_url_fn(last_browser_url)
if self._notify: if self._notify:
msg = f"{job_name}: {uploaded} Segment(e) hochgeladen" if multi:
if last_browser_url: msg = f"{job_name}: {uploaded} {label}(e) hochgeladen"
msg += f"\n{last_browser_url}" 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) self._notify("upload", "Plik-Upload", msg, ntfy_enabled)
+170 -44
View File
@@ -1,16 +1,95 @@
import asyncio
import datetime import datetime
import glob as globmod import glob as globmod
import logging
import os import os
import re import re
import shlex
import signal import signal
import subprocess import subprocess
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Optional 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: 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) 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): 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() session = get_session()
try: try:
entry = LogEntry(level=level, message=message, job_name=job_name) entry = LogEntry(level=level, message=message, job_name=job_name)
session.add(entry) session.add(entry)
session.commit() session.commit()
except Exception as e:
session.rollback()
logger.error("Log-Eintrag konnte nicht gespeichert werden: %s", e)
finally: finally:
session.close() 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( def start_recording(
self, self,
job_id: Optional[int], job_id: Optional[int],
@@ -120,13 +212,16 @@ class RecordingManager:
plik_enabled: bool = False, plik_enabled: bool = False,
delete_after_upload: bool = False, delete_after_upload: bool = False,
) -> dict: ) -> 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) stream_type = detect_stream_type(stream_url)
ext = output_format if output_format and output_format.lower() != "auto" else default_extension(stream_type) ext = output_format if output_format and output_format.lower() != "auto" else default_extension(stream_type)
sname = safe_name(name) sname = safe_name(name)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") 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) output_dir = os.path.join(download_path, sname)
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
@@ -139,7 +234,7 @@ class RecordingManager:
scheduled_stop = None scheduled_stop = None
if max_duration and max_duration > 0: 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() session = get_session()
try: try:
@@ -206,12 +301,12 @@ class RecordingManager:
try: try:
while not stop_event.is_set(): while not stop_event.is_set():
cmd = self._build_ffmpeg_cmd( log_fh = None
stream_url, stream_type, output_file,
extra_ffmpeg_args, segment_mode, segment_duration,
)
try: 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_path = os.path.join(output_dir, f".ffmpeg_{rec_id}.log")
log_fh = open(log_path, "a") log_fh = open(log_path, "a")
process = subprocess.Popen( process = subprocess.Popen(
@@ -221,6 +316,8 @@ class RecordingManager:
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
) )
except Exception as e: except Exception as e:
if log_fh:
log_fh.close()
self._update_recording(rec_id, status="error", error_message=str(e), self._update_recording(rec_id, status="error", error_message=str(e),
file_size=self._finalize_file_size(rec_id, segment_mode)) file_size=self._finalize_file_size(rec_id, segment_mode))
self._log("ERROR", f"ffmpeg konnte nicht gestartet werden: {e}", job_name) self._log("ERROR", f"ffmpeg konnte nicht gestartet werden: {e}", job_name)
@@ -229,6 +326,7 @@ class RecordingManager:
self._processes[rec_id] = process self._processes[rec_id] = process
self._update_recording(rec_id, status="running", pid=process.pid) self._update_recording(rec_id, status="running", pid=process.pid)
proc_started = time.monotonic()
process.wait() process.wait()
retcode = process.returncode retcode = process.returncode
@@ -253,6 +351,8 @@ class RecordingManager:
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode) self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
return return
if time.monotonic() - proc_started >= MIN_STABLE_RUNTIME:
retry = 0
retry += 1 retry += 1
if max_retries > 0 and retry >= max_retries: if max_retries > 0 and retry >= max_retries:
self._update_recording(rec_id, status="error", self._update_recording(rec_id, status="error",
@@ -290,7 +390,11 @@ class RecordingManager:
self._cleanup(rec_id) self._cleanup(rec_id)
def _build_ffmpeg_cmd(self, url, stream_type, output_file, extra_args, segment_mode, segment_duration): 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": if stream_type == "hls":
cmd += ["-i", url, "-c", "copy", "-bsf:a", "aac_adtstoasc"] cmd += ["-i", url, "-c", "copy", "-bsf:a", "aac_adtstoasc"]
@@ -300,8 +404,7 @@ class RecordingManager:
if segment_mode and segment_duration: if segment_mode and segment_duration:
cmd += ["-f", "segment", "-segment_time", str(segment_duration), "-reset_timestamps", "1"] cmd += ["-f", "segment", "-segment_time", str(segment_duration), "-reset_timestamps", "1"]
if extra_args: cmd += extra_tokens
cmd += extra_args.split()
cmd.append(output_file) cmd.append(output_file)
return cmd return cmd
@@ -328,23 +431,25 @@ class RecordingManager:
self._cleanup(rec_id) self._cleanup(rec_id)
return True 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() session = get_session()
try: try:
rec = session.query(Recording).filter_by(id=rec_id).first() rec = session.query(Recording).filter_by(id=rec_id).first()
if not rec or rec.status != "running": if not rec:
return False return "not_found"
if rec.status != "running":
if rec.scheduled_stop: return "not_running"
rec.scheduled_stop = rec.scheduled_stop + datetime.timedelta(seconds=extra_seconds) if not rec.scheduled_stop:
else: return "unlimited"
rec.scheduled_stop = datetime.datetime.utcnow() + datetime.timedelta(seconds=extra_seconds)
rec.scheduled_stop = rec.scheduled_stop + datetime.timedelta(seconds=extra_seconds)
job_name = rec.job_name
session.commit() session.commit()
self._log("INFO", f"Aufnahme verlängert um {format_elapsed(extra_seconds)}: {rec.job_name}", rec.job_name)
return True
finally: finally:
session.close() 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: def remove_duration_limit(self, rec_id: int) -> bool:
session = get_session() session = get_session()
@@ -388,7 +493,7 @@ class RecordingManager:
def check_scheduled_stops(self): def check_scheduled_stops(self):
session = get_session() session = get_session()
try: try:
now = datetime.datetime.utcnow() now = utcnow()
recs = session.query(Recording).filter( recs = session.query(Recording).filter(
Recording.status == "running", Recording.status == "running",
Recording.scheduled_stop.isnot(None), Recording.scheduled_stop.isnot(None),
@@ -403,25 +508,30 @@ class RecordingManager:
self.stop_recording(rec_id) self.stop_recording(rec_id)
def cleanup_stale(self): def cleanup_stale(self):
# Nach einem Neustart kann kein ffmpeg-Kindprozess mehr laufen
session = get_session() session = get_session()
try: try:
recs = session.query(Recording).filter(Recording.status.in_(["running", "starting"])).all() recs = session.query(Recording).filter(Recording.status.in_(["running", "starting"])).all()
now = utcnow()
for rec in recs: for rec in recs:
if rec.pid: rec.status = "error"
try: rec.error_message = STALE_ERROR_MESSAGE
os.kill(rec.pid, 0) rec.stopped_at = now
except (ProcessLookupError, OSError): try:
rec.status = "error" files = [rec.output_file] if rec.output_file else []
rec.error_message = "Process vanished" rec.file_size, _ = self._calc_recording_size(files, bool(rec.segment_mode))
rec.stopped_at = datetime.datetime.utcnow() except Exception:
pass
session.commit() session.commit()
if recs:
logger.warning("%d verwaiste Aufnahme(n) als abgebrochen markiert", len(recs))
finally: finally:
session.close() session.close()
def _recording_to_dict(self, rec: Recording) -> dict: def _recording_to_dict(self, rec: Recording) -> dict:
elapsed = 0 elapsed = 0
if rec.started_at: 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()) elapsed = int((end - rec.started_at).total_seconds())
total_size = 0 total_size = 0
@@ -434,7 +544,7 @@ class RecordingManager:
remaining = None remaining = None
if rec.scheduled_stop and rec.status == "running": 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)) remaining = max(0, int(diff))
return { return {
@@ -447,9 +557,9 @@ class RecordingManager:
"output_file": rec.output_file, "output_file": rec.output_file,
"pid": rec.pid, "pid": rec.pid,
"status": rec.status, "status": rec.status,
"started_at": rec.started_at.isoformat() if rec.started_at else None, "started_at": iso_utc(rec.started_at),
"stopped_at": rec.stopped_at.isoformat() if rec.stopped_at else None, "stopped_at": iso_utc(rec.stopped_at),
"scheduled_stop": rec.scheduled_stop.isoformat() if rec.scheduled_stop else None, "scheduled_stop": iso_utc(rec.scheduled_stop),
"segment_mode": rec.segment_mode, "segment_mode": rec.segment_mode,
"is_scheduled": rec.is_scheduled, "is_scheduled": rec.is_scheduled,
"error_message": rec.error_message, "error_message": rec.error_message,
@@ -463,16 +573,31 @@ class RecordingManager:
"plik_url": rec.plik_url, "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]: def _calc_recording_size(self, output_files: list[str], segment_mode: bool) -> tuple[int, int]:
total_size = 0 total_size = 0
segment_count = 0 segment_count = 0
for of in output_files: for of in output_files:
if segment_mode: if segment_mode:
pattern = of.replace('%03d', '*') for f in self._segment_files(of):
for f in globmod.glob(pattern): total_size += os.path.getsize(f)
if os.path.isfile(f): segment_count += 1
total_size += os.path.getsize(f)
segment_count += 1
else: else:
if os.path.isfile(of): if os.path.isfile(of):
total_size += os.path.getsize(of) total_size += os.path.getsize(of)
@@ -494,7 +619,7 @@ class RecordingManager:
for k, v in kwargs.items(): for k, v in kwargs.items():
setattr(rec, k, v) setattr(rec, k, v)
if kwargs.get("status") in ("stopped", "completed", "error"): if kwargs.get("status") in ("stopped", "completed", "error"):
rec.stopped_at = datetime.datetime.utcnow() rec.stopped_at = utcnow()
session.commit() session.commit()
finally: finally:
session.close() session.close()
@@ -503,9 +628,10 @@ class RecordingManager:
job_name: str, segment_mode: bool): job_name: str, segment_mode: bool):
opts = self._rec_options.get(rec_id, {}) opts = self._rec_options.get(rec_id, {})
if opts.get("plik_enabled"): 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( self._get_plik().upload_recording(
output_dir=output_dir, files=files,
output_file=output_file,
job_name=job_name, job_name=job_name,
segment_mode=segment_mode, segment_mode=segment_mode,
delete_after=opts.get("delete_after_upload", False), 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 fastapi==0.143.0
uvicorn[standard]==0.34.2 uvicorn[standard]==0.54.0
sqlalchemy==2.0.40 sqlalchemy==2.0.54
pydantic==2.11.3 pydantic==2.14.0
apscheduler==3.11.0 apscheduler==3.11.3
pyyaml==6.0.2 pyyaml==6.0.3
python-multipart==0.0.20
httpx==0.28.1 httpx==0.28.1
+237 -112
View File
@@ -1,7 +1,13 @@
import datetime import datetime
import logging
import os
import re
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import or_ 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 = { DAY_MAP = {
"mo": 1, "mon": 1, "montag": 1, "mo": 1, "mon": 1, "montag": 1,
@@ -13,6 +19,8 @@ DAY_MAP = {
"so": 7, "sun": 7, "sonntag": 7, "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: def _day_to_num(day: str) -> int:
return DAY_MAP.get(day.lower().strip(), 0) 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) parts = entry.split("-", 1)
start = _day_to_num(parts[0]) start = _day_to_num(parts[0])
end = _day_to_num(parts[1]) end = _day_to_num(parts[1])
if not start or not end:
continue
if start <= end: if start <= end:
if start <= today_num <= end: if start <= today_num <= end:
return True return True
@@ -39,7 +49,69 @@ def _matches_day(today_num: int, schedule: str) -> bool:
return False 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: def _time_to_minutes(t: str) -> int:
if not is_valid_time(t):
raise ValueError(f"Ungültige Uhrzeit: {t!r}")
parts = t.split(":") parts = t.split(":")
return int(parts[0]) * 60 + int(parts[1]) return int(parts[0]) * 60 + int(parts[1])
@@ -58,40 +130,78 @@ def _apply_buffers(job: dict) -> dict:
eff = dict(job) eff = dict(job)
pre = job.get("pre_buffer", 0) or 0 pre = job.get("pre_buffer", 0) or 0
post = job.get("post_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 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}" 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 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}" eff["schedule_stop"] = f"{m // 60:02d}:{m % 60:02d}"
return eff 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: class StreamScheduler:
def __init__(self, recorder): def __init__(self, recorder):
self.recorder = recorder self.recorder = recorder
self._scheduler = BackgroundScheduler() self._scheduler = BackgroundScheduler()
self._started_keys: set[str] = set() self._started_keys: set[str] = set()
self._last_errors: dict[int, str] = {}
def start(self): def start(self):
self._scheduler.add_job(self._tick, "interval", seconds=30, id="scheduler_tick", self._scheduler.add_job(self._tick, "interval", seconds=30, id="scheduler_tick",
replace_existing=True, max_instances=1) replace_existing=True, max_instances=1)
self._scheduler.add_job(self.recorder.check_scheduled_stops, "interval", seconds=10, self._scheduler.add_job(self.recorder.check_scheduled_stops, "interval", seconds=10,
id="check_stops", replace_existing=True, max_instances=1) 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() self._scheduler.start()
def stop(self): def stop(self):
self._scheduler.shutdown(wait=False) self._scheduler.shutdown(wait=False)
def _occurrence_key(self, job: Job) -> str: @staticmethod
today = datetime.date.today().isoformat() def prune_logs():
if job.schedule_once: try:
today = job.schedule_date or today days = int(os.environ.get("LOG_RETENTION_DAYS", "90"))
return f"{job.id}|{job.schedule_once}|{today}|{job.schedule_start}" except ValueError:
days = 90
return prune_logs(days)
def _original_occ_key(self, job: dict, now: datetime.datetime | None = None) -> str: def _original_occ_key(self, job: dict, now: datetime.datetime | None = None) -> str:
now = now or datetime.datetime.now() now = now or datetime.datetime.now()
@@ -103,7 +213,7 @@ class StreamScheduler:
now = now or datetime.datetime.now() now = now or datetime.datetime.now()
today_num = now.isoweekday() today_num = now.isoweekday()
today_date = now.strftime("%Y-%m-%d") today_date = now.strftime("%Y-%m-%d")
now_time = now.strftime("%H:%M") now_min = now.hour * 60 + now.minute
occ_key = ( occ_key = (
f"{job['id']}|{job['schedule_once']}|" f"{job['id']}|{job['schedule_once']}|"
@@ -111,58 +221,58 @@ class StreamScheduler:
f"{job['schedule_start']}" f"{job['schedule_start']}"
) )
is_today = False multi_day = _is_multi_day(job)
multi_day = bool(job.get("schedule_end_date") and job["schedule_once"]) 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_once"]:
if job["schedule_date"] == today_date: if job["schedule_date"] == today_date:
is_today = True is_today = True
else: else:
if _matches_day(today_num, job["schedule_days"] or "*"): if _matches_day(today_num, days):
is_today = True is_today = True
start_min = _time_to_minutes(start)
in_window = False in_window = False
is_cross_midnight = False is_cross_midnight = False
if is_today: if is_today:
if multi_day: if multi_day or not stop:
now_min = _time_to_minutes(now_time)
start_min = _time_to_minutes(job["schedule_start"])
in_window = now_min >= start_min in_window = now_min >= start_min
elif job["schedule_stop"]: else:
start_min = _time_to_minutes(job["schedule_start"]) stop_min = _time_to_minutes(stop)
stop_min = _time_to_minutes(job["schedule_stop"])
if stop_min <= start_min: if stop_min <= start_min:
is_cross_midnight = True is_cross_midnight = True
now_min = _time_to_minutes(now_time)
in_window = now_min >= start_min in_window = now_min >= start_min
else: else:
in_window = _in_time_window(now_time, job["schedule_start"], job["schedule_stop"]) in_window = start_min <= now_min < stop_min
else: elif stop:
now_min = _time_to_minutes(now_time) stop_min = _time_to_minutes(stop)
start_min = _time_to_minutes(job["schedule_start"]) if stop_min <= start_min and stop_min > 0 and now_min < stop_min:
in_window = now_min >= start_min yesterday = now - datetime.timedelta(days=1)
elif job.get("schedule_stop"): if job["schedule_once"]:
start_min = _time_to_minutes(job["schedule_start"]) was_yesterday = job["schedule_date"] == yesterday.strftime("%Y-%m-%d")
stop_min = _time_to_minutes(job["schedule_stop"]) else:
if stop_min <= start_min and stop_min > 0: was_yesterday = _matches_day(yesterday.isoweekday(), days)
now_min = _time_to_minutes(now_time) if was_yesterday:
if now_min < stop_min: is_cross_midnight = True
yesterday = now - datetime.timedelta(days=1) in_window = True
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
return { state["in_window"] = in_window
"occ_key": occ_key, state["is_cross_midnight"] = is_cross_midnight
"in_window": in_window, return state
"multi_day": multi_day,
"is_cross_midnight": is_cross_midnight,
}
def suppress_current_occurrence(self, job_id: int) -> bool: def suppress_current_occurrence(self, job_id: int) -> bool:
session = get_session() session = get_session()
@@ -184,9 +294,12 @@ class StreamScheduler:
finally: finally:
session.close() session.close()
occ_key = self._original_occ_key(job) try:
eff_job = _apply_buffers(job) occ_key = self._original_occ_key(job)
state = self._schedule_state(eff_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"]: if not state["in_window"]:
return False return False
@@ -202,9 +315,7 @@ class StreamScheduler:
) )
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
return local_start.astimezone(datetime.timezone.utc).replace(tzinfo=None)
local_utc_offset = datetime.datetime.now() - datetime.datetime.utcnow()
return local_start - local_utc_offset
def _has_recording_for_occurrence(self, job: dict, now: datetime.datetime) -> bool: def _has_recording_for_occurrence(self, job: dict, now: datetime.datetime) -> bool:
occurrence_start = self._occurrence_start_utc(job, now) occurrence_start = self._occurrence_start_utc(job, now)
@@ -243,14 +354,23 @@ class StreamScheduler:
).first() ).first()
if not rec: if not rec:
return None 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 None
return rec.id return rec.id
finally: finally:
session.close() 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): def _tick(self):
now = datetime.datetime.now() now = datetime.datetime.now()
self._prune_started_keys(now)
session = get_session() session = get_session()
try: try:
@@ -274,63 +394,68 @@ class StreamScheduler:
finally: finally:
session.close() session.close()
active_ids = set()
for job in job_list: for job in job_list:
if not job["schedule_start"]: active_ids.add(job["id"])
continue 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) for job_id in list(self._last_errors):
eff_job = _apply_buffers(job) if job_id not in active_ids:
state = self._schedule_state(eff_job, now) self._last_errors.pop(job_id, None)
in_window = state["in_window"]
if in_window: def _process_job(self, job: dict, now: datetime.datetime):
if ( if not job["schedule_start"]:
occ_key not in self._started_keys return
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
self.recorder.start_recording( occ_key = self._original_occ_key(job, now)
job_id=job["id"], eff_job = _apply_buffers(job)
name=job["name"], state = self._schedule_state(eff_job, now)
stream_url=job["stream_url"],
stream_type=job["stream_type"], if state["in_window"]:
output_format=job["output_format"] or "", if (
max_duration=duration, occ_key not in self._started_keys
extra_ffmpeg_args=job["extra_ffmpeg_args"] or "", and not self._is_job_recording(job["id"])
segment_duration=job["segment_duration"], and not self._has_recording_for_occurrence(job, now)
is_scheduled=True, ):
description=job.get("description", ""), duration = compute_duration(job, apply_buffers=True)
ntfy_enabled=job.get("ntfy_enabled", False), # Erst merken, damit ein Startfehler nicht alle 30s erneut versucht wird
plik_enabled=job.get("plik_enabled", False), self._started_keys.add(occ_key)
delete_after_upload=job.get("delete_after_upload", False), self.recorder.start_recording(
) job_id=job["id"],
self._started_keys.add(occ_key) name=job["name"],
else: stream_url=job["stream_url"],
if ( stream_type=job["stream_type"],
not state["multi_day"] output_format=job["output_format"] or "",
and not state["is_cross_midnight"] max_duration=duration,
and eff_job["schedule_stop"] extra_ffmpeg_args=job["extra_ffmpeg_args"] or "",
and self._is_job_recording(job["id"]) segment_duration=job["segment_duration"],
): is_scheduled=True,
rec_id = self._get_active_scheduled_recording_for_job( description=job.get("description", ""),
job["id"], respect_stop=True, ntfy_enabled=job.get("ntfy_enabled", False),
) plik_enabled=job.get("plik_enabled", False),
if rec_id: delete_after_upload=job.get("delete_after_upload", False),
self.recorder.stop_recording(rec_id) )
self._started_keys.discard(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)
+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)
+9 -3
View File
@@ -8,6 +8,7 @@
recording: recording:
# Basis-Verzeichnis für Aufnahmen # Basis-Verzeichnis für Aufnahmen
# Muss innerhalb von RECORDING_ROOTS liegen (Standard: /app/data/recordings:/recordings)
download_path: /app/data/recordings download_path: /app/data/recordings
# Wiederholungsversuche bei Verbindungsabbruch (0 = unbegrenzt) # Wiederholungsversuche bei Verbindungsabbruch (0 = unbegrenzt)
@@ -16,17 +17,21 @@ recording:
# Wartezeit zwischen Wiederholungsversuchen (Sekunden) # Wartezeit zwischen Wiederholungsversuchen (Sekunden)
retry_delay: 5 retry_delay: 5
# Wird nur aus dieser Datei gelesen (nicht über Web-UI/API änderbar)
server: server:
host: "0.0.0.0" host: "0.0.0.0"
port: 8484 port: 8484
# Optionale Basic-Auth # Basic-Auth (beim ersten Start aktiv, zufälliges Passwort steht im Container-Log)
# Das Passwort wird als PBKDF2-Hash (password_hash) gespeichert und über das
# Web-UI gesetzt. Alternativ "password: <klartext>" eintragen - der Klartext
# wird beim nächsten Start automatisch in einen Hash umgewandelt.
auth: auth:
enabled: false enabled: true
username: admin username: admin
password: stream-recorder
# NTFY Push-Benachrichtigungen (optional) # NTFY Push-Benachrichtigungen (optional)
# Token und API-Key werden von der API nie zurückgegeben.
ntfy: ntfy:
url: "" url: ""
token: "" token: ""
@@ -43,5 +48,6 @@ schedule:
default_pre_buffer: 5 default_pre_buffer: 5
default_post_buffer: 5 default_post_buffer: 5
# Log-Level (DEBUG, INFO, WARN, ERROR)
logging: logging:
level: INFO level: INFO
+77
View File
@@ -0,0 +1,77 @@
#!/bin/sh
# stream-recorder - Container-Entrypoint
#
# Läuft der Container als root, werden UID/GID des Users "app" an PUID/PGID
# angepasst, die Datenverzeichnisse übernommen und der Prozess anschließend
# ohne Root-Rechte gestartet. Läuft der Container bereits als Nicht-root
# (z.B. "user:" in docker-compose), wird der Befehl direkt ausgeführt.
set -eu
PUID="${PUID:-1000}"
PGID="${PGID:-1000}"
DATA_DIR="${DATA_DIR:-/app/data}"
RECORDING_ROOTS="${RECORDING_ROOTS:-/app/data/recordings:/recordings}"
log() {
echo "[entrypoint] $*"
}
# Nur Dateien/Verzeichnisse umschreiben, die noch nicht PUID:PGID gehören.
fix_owner() {
if [ -d "$1" ]; then
find "$1" \( ! -user "$PUID" -o ! -group "$PGID" \) \
-exec chown "$PUID:$PGID" {} + 2>/dev/null \
|| log "WARNUNG: Besitzrechte in $1 konnten nicht vollständig angepasst werden"
fi
}
if [ "$(id -u)" != "0" ]; then
exec "$@"
fi
case "$PUID" in
''|*[!0-9]*) log "FEHLER: PUID muss numerisch sein (aktuell: $PUID)"; exit 1 ;;
esac
case "$PGID" in
''|*[!0-9]*) log "FEHLER: PGID muss numerisch sein (aktuell: $PGID)"; exit 1 ;;
esac
if [ "$PUID" = "0" ]; then
log "WARNUNG: PUID=0 - Anwendung läuft als root"
exec "$@"
fi
# UID/GID des app-Users anpassen (-o erlaubt bereits vergebene IDs)
if [ "$(id -g app)" != "$PGID" ]; then
log "Setze GID von app auf $PGID"
groupmod -o -g "$PGID" app
fi
if [ "$(id -u app)" != "$PUID" ]; then
log "Setze UID von app auf $PUID"
usermod -o -u "$PUID" app
fi
mkdir -p "$DATA_DIR"
fix_owner "$DATA_DIR"
OLD_IFS="$IFS"
IFS=':'
for root in $RECORDING_ROOTS; do
IFS="$OLD_IFS"
if [ -n "$root" ]; then
fix_owner "$root"
fi
done
IFS="$OLD_IFS"
if [ -f "$DATA_DIR/config.yml" ]; then
chmod 600 "$DATA_DIR/config.yml" || log "WARNUNG: chmod 600 auf config.yml fehlgeschlagen"
fi
if ! command -v setpriv >/dev/null 2>&1; then
log "FEHLER: setpriv nicht gefunden (Paket util-linux) - Rechteabgabe nicht möglich"
exit 1
fi
export HOME=/tmp
exec setpriv --reuid="$PUID" --regid="$PGID" --init-groups "$@"
+358 -145
View File
@@ -256,7 +256,7 @@
.log-time { color: var(--text-dim); white-space: nowrap; } .log-time { color: var(--text-dim); white-space: nowrap; }
.log-level { font-weight: 600; min-width: 42px; } .log-level { font-weight: 600; min-width: 42px; }
.log-level.INFO { color: var(--accent); } .log-level.INFO { color: var(--accent); }
.log-level.WARN { color: var(--warning); } .log-level.WARN, .log-level.WARNING { color: var(--warning); }
.log-level.ERROR { color: var(--error); } .log-level.ERROR { color: var(--error); }
.log-level.DEBUG { color: var(--text-dim); } .log-level.DEBUG { color: var(--text-dim); }
.log-msg { color: var(--text); word-break: break-all; } .log-msg { color: var(--text); word-break: break-all; }
@@ -437,11 +437,11 @@
<h1><img src="/logo.png" alt="Logo" class="header-logo" id="headerLogo"> Stream-Recorder</h1> <h1><img src="/logo.png" alt="Logo" class="header-logo" id="headerLogo"> Stream-Recorder</h1>
<button class="burger" id="burgerBtn" onclick="toggleBurger()" aria-label="Menü">&#9776;</button> <button class="burger" id="burgerBtn" onclick="toggleBurger()" aria-label="Menü">&#9776;</button>
<div class="tabs" id="navTabs"> <div class="tabs" id="navTabs">
<button class="tab active" onclick="switchTab('dashboard')">Dashboard</button> <button class="tab active" data-tab="dashboard" onclick="switchTab('dashboard')">Dashboard</button>
<button class="tab" onclick="switchTab('jobs')">Jobs</button> <button class="tab" data-tab="jobs" onclick="switchTab('jobs')">Jobs</button>
<button class="tab" onclick="switchTab('stations')">Sender</button> <button class="tab" data-tab="stations" onclick="switchTab('stations')">Sender</button>
<button class="tab" onclick="switchTab('logs')">Logs</button> <button class="tab" data-tab="logs" onclick="switchTab('logs')">Logs</button>
<button class="tab" onclick="switchTab('settings')">Einstellungen</button> <button class="tab" data-tab="settings" onclick="switchTab('settings')">Einstellungen</button>
</div> </div>
</header> </header>
@@ -727,15 +727,16 @@
<div class="modal" style="max-width:400px"> <div class="modal" style="max-width:400px">
<div class="modal-title">Aufnahme verlängern</div> <div class="modal-title">Aufnahme verlängern</div>
<input type="hidden" id="extend-rec-id"> <input type="hidden" id="extend-rec-id">
<p style="color:var(--text-dim);font-size:0.85rem;margin-bottom:12px">Wie lange soll die Aufnahme verlängert werden?</p> <p id="extend-question" style="color:var(--text-dim);font-size:0.85rem;margin-bottom:12px">Wie lange soll die Aufnahme verlängert werden?</p>
<div class="extend-options"> <p id="extend-unlimited-info" style="display:none;color:var(--text-dim);font-size:0.85rem;margin-bottom:12px">&#8734; Diese Aufnahme ist bereits unbegrenzt und kann nicht weiter verlängert werden.</p>
<div class="extend-options" id="extend-options">
<button class="btn" onclick="doExtend(15)">+15 Min</button> <button class="btn" onclick="doExtend(15)">+15 Min</button>
<button class="btn" onclick="doExtend(30)">+30 Min</button> <button class="btn" onclick="doExtend(30)">+30 Min</button>
<button class="btn" onclick="doExtend(60)">+1 Std</button> <button class="btn" onclick="doExtend(60)">+1 Std</button>
<button class="btn" onclick="doExtend(120)">+2 Std</button> <button class="btn" onclick="doExtend(120)">+2 Std</button>
<button class="btn btn-success" onclick="doUnlimited()">Unbegrenzt</button> <button class="btn btn-success" onclick="doUnlimited()">Unbegrenzt</button>
</div> </div>
<div style="margin-top:16px"> <div style="margin-top:16px" id="extend-custom-group">
<label style="font-size:0.8rem;color:var(--text-dim)">Eigene Dauer (Minuten)</label> <label style="font-size:0.8rem;color:var(--text-dim)">Eigene Dauer (Minuten)</label>
<div style="display:flex;gap:8px;margin-top:4px"> <div style="display:flex;gap:8px;margin-top:4px">
<input type="number" id="extend-custom" placeholder="z.B. 45" style="flex:1"> <input type="number" id="extend-custom" placeholder="z.B. 45" style="flex:1">
@@ -766,16 +767,46 @@
const API = ''; const API = '';
let stationsCache = []; let stationsCache = [];
async function api(path, opts = {}) { let authLost = false;
const res = await fetch(API + path, {
headers: { 'Content-Type': 'application/json', ...opts.headers }, function formatErrorDetail(detail) {
...opts, if (Array.isArray(detail)) {
}); return detail.map(item => {
if (!res.ok) { if (item && typeof item === 'object') {
const err = await res.json().catch(() => ({})); const loc = Array.isArray(item.loc) ? item.loc.slice(1).join('.') : '';
throw new Error(err.detail || res.statusText); const msg = item.msg != null ? String(item.msg) : '';
return loc ? `${loc}: ${msg}` : msg;
}
return String(item);
}).join('\n');
} }
return res.json(); if (typeof detail === 'string') return detail;
if (detail && typeof detail === 'object') return detail.msg ? String(detail.msg) : JSON.stringify(detail);
return '';
}
async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (opts.body !== undefined && opts.body !== null && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
let res;
try {
res = await fetch(API + path, { ...opts, headers });
} catch (e) {
throw new Error('Server nicht erreichbar');
}
if (!res.ok) {
if (res.status === 401) {
authLost = true;
throw new Error('Nicht angemeldet – Seite neu laden');
}
if (res.status === 429) throw new Error('Zu viele Fehlversuche, bitte später erneut versuchen');
const err = await res.json().catch(() => ({}));
throw new Error(formatErrorDetail(err.detail) || res.statusText || ('HTTP ' + res.status));
}
const text = await res.text();
return text ? JSON.parse(text) : null;
} }
// Burger menu // Burger menu
@@ -786,20 +817,11 @@ function toggleBurger() {
// Tabs // Tabs
function switchTab(name) { function switchTab(name) {
document.getElementById('navTabs').classList.remove('open'); document.getElementById('navTabs').classList.remove('open');
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); const panel = document.getElementById('panel-' + name);
if (!panel) return;
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.querySelector(`#panel-${name}`).classList.add('active'); panel.classList.add('active');
const tabLabel = { document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
dashboard: 'dash',
jobs: 'jobs',
stations: 'sender',
logs: 'logs',
settings: 'einst',
}[name] || name;
document.querySelectorAll('.tab').forEach(t => {
if (t.textContent.toLowerCase().includes(tabLabel))
t.classList.add('active');
});
if (name === 'logs') loadLogs(); if (name === 'logs') loadLogs();
if (name === 'jobs') loadJobs(); if (name === 'jobs') loadJobs();
if (name === 'stations') loadStations(); if (name === 'stations') loadStations();
@@ -807,18 +829,35 @@ function switchTab(name) {
} }
// Dashboard // Dashboard
async function loadDashboard() { let dashInFlight = false;
try { let dashPending = false;
const active = await api('/api/recordings?active_only=true');
const recent = await api('/api/recordings?limit=20');
renderActive(active); async function loadDashboard() {
renderRecent(recent.filter(r => r.status !== 'running' && r.status !== 'starting')); if (dashInFlight) { dashPending = true; return; }
dashInFlight = true;
try {
const [active, recent] = await Promise.all([
api('/api/recordings?active_only=true'),
api('/api/recordings?limit=20'),
]);
renderActive(active || []);
renderRecent((recent || []).filter(r => r.status !== 'running' && r.status !== 'starting'));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally {
dashInFlight = false;
if (dashPending) {
dashPending = false;
loadDashboard();
}
} }
} }
function pollDashboard() {
if (document.hidden || authLost) return;
loadDashboard();
}
function renderActive(recs) { function renderActive(recs) {
const el = document.getElementById('activeRecordings'); const el = document.getElementById('activeRecordings');
if (!recs.length) { if (!recs.length) {
@@ -826,8 +865,11 @@ function renderActive(recs) {
return; return;
} }
el.innerHTML = recs.map(r => { el.innerHTML = recs.map(r => {
const remainClass = r.remaining !== null const id = intId(r.id);
? (r.remaining < 300 ? 'critical' : r.remaining < 900 ? 'low' : '') const hasLimit = r.remaining !== null && r.remaining !== undefined;
const remaining = Number(r.remaining);
const remainClass = hasLimit
? (remaining < 300 ? 'critical' : remaining < 900 ? 'low' : '')
: ''; : '';
return ` return `
<div class="card"> <div class="card">
@@ -835,19 +877,19 @@ function renderActive(recs) {
<div class="card-title"> <div class="card-title">
<span class="rec-dot"></span> <span class="rec-dot"></span>
${esc(r.job_name)} ${esc(r.job_name)}
<span class="badge badge-${r.status}">${r.status}</span> <span class="badge ${statusClass(r.status)}">${esc(r.status)}</span>
${r.is_scheduled ? '<span class="badge badge-scheduled">geplant</span>' : ''} ${r.is_scheduled ? '<span class="badge badge-scheduled">geplant</span>' : ''}
</div> </div>
<div class="actions"> ${id !== null ? `<div class="actions">
<button class="btn btn-sm btn-success" onclick="openExtend(${r.id})">Verlängern</button> <button class="btn btn-sm btn-success" onclick="openExtend(${id}, ${hasLimit ? 'true' : 'false'})">Verlängern</button>
<button class="btn btn-sm btn-danger" onclick="stopRec(${r.id})">Stoppen</button> <button class="btn btn-sm btn-danger" onclick="stopRec(${id})">Stoppen</button>
</div> </div>` : ''}
</div> </div>
<div class="meta"> <div class="meta">
<span class="meta-item">&#128339; ${r.elapsed_display}</span> <span class="meta-item">&#128339; ${esc(r.elapsed_display)}</span>
<span class="meta-item">&#128190; ${r.total_size_display}</span> <span class="meta-item">&#128190; ${esc(r.total_size_display)}</span>
${r.segment_count !== null ? `<span class="meta-item">&#128196; ${r.segment_count} Segmente</span>` : ''} ${r.segment_count != null ? `<span class="meta-item">&#128196; ${esc(r.segment_count)} Segmente</span>` : ''}
${r.remaining !== null ? `<span class="meta-item">Verbleibend: <span class="remaining ${remainClass}">${r.remaining_display}</span></span>` : '<span class="meta-item">&#8734; Unbegrenzt</span>'} ${hasLimit ? `<span class="meta-item">Verbleibend: <span class="remaining ${remainClass}">${esc(r.remaining_display)}</span></span>` : '<span class="meta-item">&#8734; Unbegrenzt</span>'}
</div> </div>
<div class="meta" style="margin-top:4px"> <div class="meta" style="margin-top:4px">
<span class="meta-item" style="opacity:0.6">${esc(r.stream_url)}</span> <span class="meta-item" style="opacity:0.6">${esc(r.stream_url)}</span>
@@ -863,19 +905,20 @@ function renderRecent(recs) {
return; return;
} }
el.innerHTML = recs.slice(0, 10).map(r => { el.innerHTML = recs.slice(0, 10).map(r => {
const started = r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : ''; const started = fmtDate(r.started_at, '');
const id = intId(r.id);
return ` return `
<div class="card card-recent" style="padding:12px" onclick="openRecDetail(${r.id})"> <div class="card card-recent" style="padding:12px"${id !== null ? ` onclick="openRecDetail(${id})"` : ''}>
<div style="display:flex;align-items:center;justify-content:space-between"> <div style="display:flex;align-items:center;justify-content:space-between">
<div> <div>
<span style="font-weight:500">${esc(r.job_name)}</span> <span style="font-weight:500">${esc(r.job_name)}</span>
<span class="badge badge-${r.status}" style="margin-left:8px">${r.status}</span> <span class="badge ${statusClass(r.status)}" style="margin-left:8px">${esc(r.status)}</span>
${r.plik_url ? '<span class="badge badge-completed" style="margin-left:4px">&#128228; Plik</span>' : ''} ${r.plik_url ? '<span class="badge badge-completed" style="margin-left:4px">&#128228; Plik</span>' : ''}
</div> </div>
<div class="recent-meta"> <div class="recent-meta">
<span class="rm-dur">&#128339; ${r.elapsed_display}</span> <span class="rm-dur">&#128339; ${esc(r.elapsed_display)}</span>
<span class="rm-size">&#128190; ${r.total_size_display}</span> <span class="rm-size">&#128190; ${esc(r.total_size_display)}</span>
<span class="rm-date">${started}</span> <span class="rm-date">${esc(started)}</span>
</div> </div>
</div> </div>
</div>`; </div>`;
@@ -884,22 +927,28 @@ function renderRecent(recs) {
async function openRecDetail(recId) { async function openRecDetail(recId) {
try { try {
const r = await api('/api/recordings/' + recId); const id = intId(recId);
const started = r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : '—'; if (id === null) return;
const stopped = r.stopped_at ? new Date(r.stopped_at).toLocaleString('de-DE') : '—'; const r = await api('/api/recordings/' + id);
const fileName = r.output_file ? r.output_file.split('/').pop() : null; const started = fmtDate(r.started_at, '—');
const stopped = fmtDate(r.stopped_at, '—');
const fileName = r.output_file ? String(r.output_file).split('/').pop() : null;
document.getElementById('recDetailTitle').textContent = r.job_name; document.getElementById('recDetailTitle').textContent = r.job_name || '';
document.getElementById('recDetailBadges').innerHTML = document.getElementById('recDetailBadges').innerHTML =
`<span class="badge badge-${r.status}">${r.status}</span>` + `<span class="badge ${statusClass(r.status)}">${esc(r.status)}</span>` +
(r.plik_url ? ' <span class="badge badge-completed">&#128228; Plik</span>' : ''); (r.plik_url ? ' <span class="badge badge-completed">&#128228; Plik</span>' : '');
let html = ''; let html = '';
if (r.plik_url) { if (r.plik_url) {
const plikUrl = String(r.plik_url);
const plikValue = /^https?:\/\//i.test(plikUrl)
? `<a class="rec-plik-link" href="${esc(plikUrl)}" target="_blank" rel="noopener noreferrer">&#128279; Im Browser öffnen</a>`
: esc(plikUrl);
html += `<div class="rec-detail-item full"> html += `<div class="rec-detail-item full">
<span class="rec-detail-label">Plik-Download</span> <span class="rec-detail-label">Plik-Download</span>
<span class="rec-detail-value"><a class="rec-plik-link" href="${esc(r.plik_url)}" target="_blank" rel="noopener">&#128279; Im Browser öffnen</a></span> <span class="rec-detail-value">${plikValue}</span>
</div> </div>
<hr class="rec-detail-divider">`; <hr class="rec-detail-divider">`;
} }
@@ -907,19 +956,19 @@ async function openRecDetail(recId) {
html += ` html += `
<div class="rec-detail-item"> <div class="rec-detail-item">
<span class="rec-detail-label">Gestartet</span> <span class="rec-detail-label">Gestartet</span>
<span class="rec-detail-value">${started}</span> <span class="rec-detail-value">${esc(started)}</span>
</div> </div>
<div class="rec-detail-item"> <div class="rec-detail-item">
<span class="rec-detail-label">Beendet</span> <span class="rec-detail-label">Beendet</span>
<span class="rec-detail-value">${stopped}</span> <span class="rec-detail-value">${esc(stopped)}</span>
</div> </div>
<div class="rec-detail-item"> <div class="rec-detail-item">
<span class="rec-detail-label">Dauer</span> <span class="rec-detail-label">Dauer</span>
<span class="rec-detail-value">${r.elapsed_display}</span> <span class="rec-detail-value">${esc(r.elapsed_display)}</span>
</div> </div>
<div class="rec-detail-item"> <div class="rec-detail-item">
<span class="rec-detail-label">Dateigröße</span> <span class="rec-detail-label">Dateigröße</span>
<span class="rec-detail-value">${r.total_size_display}</span> <span class="rec-detail-value">${esc(r.total_size_display)}</span>
</div>`; </div>`;
if (r.error_message) { if (r.error_message) {
@@ -952,7 +1001,7 @@ async function openRecDetail(recId) {
document.getElementById('recDetailBody').innerHTML = html; document.getElementById('recDetailBody').innerHTML = html;
openModal('recDetailModal'); openModal('recDetailModal');
} catch (e) { } catch (e) {
console.error(e); alert('Fehler: ' + e.message);
} }
} }
@@ -971,9 +1020,11 @@ function populateStationSelects() {
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el) return; if (!el) return;
const selected = el.value; const selected = el.value;
el.innerHTML = '<option value="">URL manuell eingeben</option>' + stationsCache.map(s => el.innerHTML = '<option value="">URL manuell eingeben</option>' + stationsCache.map(s => {
`<option value="${s.id}">${esc(s.name)}${s.description ? ' - ' + esc(s.description) : ''}</option>` const id = intId(s.id);
).join(''); if (id === null) return '';
return `<option value="${id}">${esc(s.name)}${s.description ? ' - ' + esc(s.description) : ''}</option>`;
}).join('');
if (stationsCache.some(s => String(s.id) === selected)) el.value = selected; if (stationsCache.some(s => String(s.id) === selected)) el.value = selected;
}); });
} }
@@ -986,31 +1037,34 @@ async function loadStations() {
el.innerHTML = '<div class="empty"><div class="empty-icon">&#128251;</div>Keine Sender gespeichert<br><button class="btn btn-primary" style="margin-top:12px" onclick="openStationModal()">Ersten Sender anlegen</button></div>'; el.innerHTML = '<div class="empty"><div class="empty-icon">&#128251;</div>Keine Sender gespeichert<br><button class="btn btn-primary" style="margin-top:12px" onclick="openStationModal()">Ersten Sender anlegen</button></div>';
return; return;
} }
el.innerHTML = stationsCache.map(s => ` el.innerHTML = stationsCache.map(s => {
const id = intId(s.id);
return `
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="card-title"> <div class="card-title">
${esc(s.name)} ${esc(s.name)}
<span class="badge badge-scheduled">${esc(s.stream_type || 'auto')}</span> <span class="badge badge-scheduled">${esc(s.stream_type || 'auto')}</span>
</div> </div>
<div class="actions"> ${id !== null ? `<div class="actions">
<button class="btn btn-sm" onclick="openStationModal(${s.id})">Bearbeiten</button> <button class="btn btn-sm" onclick="openStationModal(${id})">Bearbeiten</button>
<button class="btn btn-sm btn-danger" onclick="deleteStation(${s.id})">Löschen</button> <button class="btn btn-sm btn-danger" onclick="deleteStation(${id})">Löschen</button>
</div> </div>` : ''}
</div> </div>
${s.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(s.description)}</span></div>` : ''} ${s.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(s.description)}</span></div>` : ''}
<div class="meta"> <div class="meta">
<span class="meta-item">${esc(s.stream_url)}</span> <span class="meta-item">${esc(s.stream_url)}</span>
</div> </div>
</div> </div>
`).join(''); `;
}).join('');
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
function openStationModal(id = null) { function openStationModal(id = null) {
const station = id ? stationsCache.find(s => s.id === id) : null; const station = id ? stationsCache.find(s => intId(s.id) === intId(id)) : null;
document.getElementById('station-edit-id').value = station ? station.id : ''; document.getElementById('station-edit-id').value = station ? station.id : '';
document.getElementById('stationModalTitle').textContent = station ? 'Sender bearbeiten' : 'Neuer Sender'; document.getElementById('stationModalTitle').textContent = station ? 'Sender bearbeiten' : 'Neuer Sender';
document.getElementById('station-name').value = station ? station.name : ''; document.getElementById('station-name').value = station ? station.name : '';
@@ -1048,8 +1102,12 @@ async function saveStation() {
async function deleteStation(id) { async function deleteStation(id) {
if (!confirm('Sender wirklich löschen?')) return; if (!confirm('Sender wirklich löschen?')) return;
await api(`/api/stations/${id}`, { method: 'DELETE' }); try {
loadStations(); await api(`/api/stations/${intId(id)}`, { method: 'DELETE' });
loadStations();
} catch (e) {
alert('Fehler: ' + e.message);
}
} }
function applyStationToQuickRecord() { function applyStationToQuickRecord() {
@@ -1087,7 +1145,9 @@ async function loadJobs() {
el.innerHTML = '<div class="empty"><div class="empty-icon">&#128203;</div>Keine Jobs konfiguriert<br><button class="btn btn-primary" style="margin-top:12px" onclick="openJobModal()">Ersten Job erstellen</button></div>'; el.innerHTML = '<div class="empty"><div class="empty-icon">&#128203;</div>Keine Jobs konfiguriert<br><button class="btn btn-primary" style="margin-top:12px" onclick="openJobModal()">Ersten Job erstellen</button></div>';
return; return;
} }
el.innerHTML = jobs.map(j => ` el.innerHTML = jobs.map(j => {
const id = intId(j.id);
return `
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="card-title"> <div class="card-title">
@@ -1095,24 +1155,25 @@ async function loadJobs() {
${j.is_recording ? '<span class="badge badge-running">läuft</span>' : ''} ${j.is_recording ? '<span class="badge badge-running">läuft</span>' : ''}
${j.schedule_enabled ? '<span class="badge badge-scheduled">geplant</span>' : ''} ${j.schedule_enabled ? '<span class="badge badge-scheduled">geplant</span>' : ''}
</div> </div>
<div class="actions"> ${id !== null ? `<div class="actions">
${!j.is_recording ? `<button class="btn btn-sm btn-success" onclick="startJob(${j.id})">Aufnehmen</button>` : ''} ${!j.is_recording ? `<button class="btn btn-sm btn-success" onclick="startJob(${id})">Aufnehmen</button>` : ''}
<button class="btn btn-sm" onclick="editJob(${j.id})">Bearbeiten</button> <button class="btn btn-sm" onclick="editJob(${id})">Bearbeiten</button>
<button class="btn btn-sm btn-danger" onclick="deleteJob(${j.id})">Löschen</button> <button class="btn btn-sm btn-danger" onclick="deleteJob(${id})">Löschen</button>
</div> </div>` : ''}
</div> </div>
${j.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(j.description)}</span></div>` : ''} ${j.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(j.description)}</span></div>` : ''}
<div class="meta"> <div class="meta">
<span class="meta-item">${esc(j.stream_type || 'auto')}</span> <span class="meta-item">${esc(j.stream_type || 'auto')}</span>
<span class="meta-item">${j.station_name ? '&#128251; ' + esc(j.station_name) : esc(j.stream_url)}</span> <span class="meta-item">${j.station_name ? '&#128251; ' + esc(j.station_name) : esc(j.stream_url)}</span>
${j.max_duration ? `<span class="meta-item">&#128339; ${Math.round(j.max_duration/60)} Min</span>` : ''} ${j.max_duration ? `<span class="meta-item">&#128339; ${esc(Math.round(j.max_duration/60))} Min</span>` : ''}
${j.segment_duration ? `<span class="meta-item">&#128196; alle ${Math.round(j.segment_duration/60)} Min</span>` : ''} ${j.segment_duration ? `<span class="meta-item">&#128196; alle ${esc(Math.round(j.segment_duration/60))} Min</span>` : ''}
${j.schedule_enabled ? `<span class="meta-item">&#128197; ${j.schedule_once ? j.schedule_date : j.schedule_days} ${j.schedule_start}${j.schedule_stop ? ' - '+j.schedule_stop : ''}${j.pre_buffer ? ' (&#8722;'+j.pre_buffer+'min)' : ''}${j.post_buffer ? ' (+'+j.post_buffer+'min)' : ''}</span>` : ''} ${j.schedule_enabled ? `<span class="meta-item">&#128197; ${esc(j.schedule_once ? j.schedule_date : j.schedule_days)} ${esc(j.schedule_start)}${j.schedule_stop ? ' - ' + esc(j.schedule_stop) : ''}${j.pre_buffer ? ' (&#8722;' + esc(j.pre_buffer) + 'min)' : ''}${j.post_buffer ? ' (+' + esc(j.post_buffer) + 'min)' : ''}</span>` : ''}
${j.ntfy_enabled ? `<span class="meta-item">&#128276; NTFY</span>` : ''} ${j.ntfy_enabled ? `<span class="meta-item">&#128276; NTFY</span>` : ''}
${j.plik_enabled ? `<span class="meta-item">&#128228; Plik${j.delete_after_upload ? ' (danach löschen)' : ''}</span>` : ''} ${j.plik_enabled ? `<span class="meta-item">&#128228; Plik${j.delete_after_upload ? ' (danach löschen)' : ''}</span>` : ''}
</div> </div>
</div> </div>
`).join(''); `;
}).join('');
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
@@ -1155,10 +1216,16 @@ function openJobModal(job = null) {
} }
async function editJob(id) { async function editJob(id) {
const job = await api(`/api/jobs/${id}`); try {
openJobModal(job); const job = await api(`/api/jobs/${intId(id)}`);
openJobModal(job);
} catch (e) {
alert('Fehler: ' + e.message);
}
} }
function isUInt(v) { return /^\d+$/.test(String(v).trim()); }
async function saveJob() { async function saveJob() {
const editId = document.getElementById('job-edit-id').value; const editId = document.getElementById('job-edit-id').value;
const durMin = document.getElementById('job-duration').value; const durMin = document.getElementById('job-duration').value;
@@ -1191,10 +1258,40 @@ async function saveJob() {
alert('Name und URL sind Pflichtfelder'); alert('Name und URL sind Pflichtfelder');
return; return;
} }
if (durMin && (!isUInt(durMin) || parseInt(durMin) < 1)) {
alert('Max. Dauer muss eine ganze Zahl ≥ 1 (Minuten) sein');
return;
}
if (segMin && (!isUInt(segMin) || parseInt(segMin) < 1)) {
alert('Segment-Dauer muss eine ganze Zahl ≥ 1 (Minuten) sein');
return;
}
if (data.schedule_enabled) {
if (!data.schedule_start) {
alert('Bei aktivem Zeitplan ist eine Startzeit erforderlich');
return;
}
if (data.schedule_once && !data.schedule_date) {
alert('Bei einmaligem Zeitplan ist ein Startdatum erforderlich');
return;
}
if (data.schedule_once && data.schedule_end_date && data.schedule_date && data.schedule_end_date < data.schedule_date) {
alert('Enddatum darf nicht vor dem Startdatum liegen');
return;
}
}
for (const [key, label] of [['pre', 'Vorlaufzeit'], ['post', 'Nachlaufzeit']]) {
if (!document.getElementById(`job-${key}-buffer-enabled`).checked) continue;
const v = document.getElementById(`job-${key}-buffer`).value;
if (v && (!isUInt(v) || parseInt(v) > 120)) {
alert(`${label} muss eine ganze Zahl zwischen 0 und 120 Minuten sein`);
return;
}
}
try { try {
if (editId) { if (editId) {
await api(`/api/jobs/${editId}`, { method: 'PUT', body: JSON.stringify(data) }); await api(`/api/jobs/${intId(editId)}`, { method: 'PUT', body: JSON.stringify(data) });
} else { } else {
await api('/api/jobs', { method: 'POST', body: JSON.stringify(data) }); await api('/api/jobs', { method: 'POST', body: JSON.stringify(data) });
} }
@@ -1207,13 +1304,17 @@ async function saveJob() {
async function deleteJob(id) { async function deleteJob(id) {
if (!confirm('Job wirklich löschen?')) return; if (!confirm('Job wirklich löschen?')) return;
await api(`/api/jobs/${id}`, { method: 'DELETE' }); try {
loadJobs(); await api(`/api/jobs/${intId(id)}`, { method: 'DELETE' });
loadJobs();
} catch (e) {
alert('Fehler: ' + e.message);
}
} }
async function startJob(id) { async function startJob(id) {
try { try {
await api(`/api/jobs/${id}/record`, { method: 'POST' }); await api(`/api/jobs/${intId(id)}/record`, { method: 'POST' });
loadDashboard(); loadDashboard();
loadJobs(); loadJobs();
} catch (e) { } catch (e) {
@@ -1323,6 +1424,10 @@ async function startQuickRecord() {
const url = document.getElementById('qr-url').value; const url = document.getElementById('qr-url').value;
if (!url) { alert('URL ist ein Pflichtfeld'); return; } if (!url) { alert('URL ist ein Pflichtfeld'); return; }
const durMin = document.getElementById('qr-duration').value; const durMin = document.getElementById('qr-duration').value;
if (durMin && (!isUInt(durMin) || parseInt(durMin) < 1)) {
alert('Maximale Dauer muss eine ganze Zahl ≥ 1 (Minuten) sein');
return;
}
try { try {
await api('/api/quick-record', { await api('/api/quick-record', {
method: 'POST', method: 'POST',
@@ -1344,54 +1449,97 @@ async function startQuickRecord() {
// Recording actions // Recording actions
async function stopRec(id) { async function stopRec(id) {
if (!confirm('Aufnahme wirklich stoppen?')) return; if (!confirm('Aufnahme wirklich stoppen?')) return;
await api(`/api/recordings/${id}/stop`, { method: 'POST' }); try {
loadDashboard(); await api(`/api/recordings/${intId(id)}/stop`, { method: 'POST' });
loadDashboard();
} catch (e) {
alert('Fehler: ' + e.message);
}
} }
function openExtend(id) { function openExtend(id, hasLimit = true) {
document.getElementById('extend-rec-id').value = id; document.getElementById('extend-rec-id').value = intId(id) ?? '';
document.getElementById('extend-custom').value = ''; document.getElementById('extend-custom').value = '';
document.getElementById('extend-question').style.display = hasLimit ? '' : 'none';
document.getElementById('extend-options').style.display = hasLimit ? '' : 'none';
document.getElementById('extend-custom-group').style.display = hasLimit ? '' : 'none';
document.getElementById('extend-unlimited-info').style.display = hasLimit ? 'none' : '';
openModal('extendModal'); openModal('extendModal');
} }
async function doExtend(minutes) { async function doExtend(minutes) {
const id = document.getElementById('extend-rec-id').value; const id = intId(document.getElementById('extend-rec-id').value);
await api(`/api/recordings/${id}/extend?minutes=${minutes}`, { method: 'POST' }); const min = Number(minutes);
closeModal('extendModal'); if (id === null) return;
loadDashboard(); if (!Number.isInteger(min) || min < 1 || min > 1440) {
alert('Bitte eine ganze Zahl zwischen 1 und 1440 Minuten eingeben');
return;
}
try {
await api(`/api/recordings/${id}/extend?minutes=${min}`, { method: 'POST' });
closeModal('extendModal');
loadDashboard();
} catch (e) {
alert('Fehler: ' + e.message);
}
} }
async function doExtendCustom() { async function doExtendCustom() {
const min = document.getElementById('extend-custom').value; const min = document.getElementById('extend-custom').value;
if (!min || min <= 0) return; if (!isUInt(min) || parseInt(min) < 1 || parseInt(min) > 1440) {
alert('Bitte eine ganze Zahl zwischen 1 und 1440 Minuten eingeben');
return;
}
await doExtend(parseInt(min)); await doExtend(parseInt(min));
} }
async function doUnlimited() { async function doUnlimited() {
const id = document.getElementById('extend-rec-id').value; const id = intId(document.getElementById('extend-rec-id').value);
await api(`/api/recordings/${id}/unlimited`, { method: 'POST' }); if (id === null) return;
closeModal('extendModal'); try {
loadDashboard(); await api(`/api/recordings/${id}/unlimited`, { method: 'POST' });
closeModal('extendModal');
loadDashboard();
} catch (e) {
alert('Fehler: ' + e.message);
}
} }
// Logs // Logs
const LOG_LEVELS = new Set(['DEBUG', 'INFO', 'WARN', 'WARNING', 'ERROR']);
async function loadLogs() { async function loadLogs() {
const level = document.getElementById('logLevel').value; try {
const logs = await api(`/api/logs?limit=200${level ? '&level='+level : ''}`); const level = document.getElementById('logLevel').value;
const el = document.getElementById('logContainer'); const logs = await api(`/api/logs?limit=200${level ? '&level=' + encodeURIComponent(level) : ''}`) || [];
if (!logs.length) { const el = document.getElementById('logContainer');
el.innerHTML = '<span style="color:var(--text-dim)">Keine Logeinträge</span>'; if (!logs.length) {
return; el.innerHTML = '<span style="color:var(--text-dim)">Keine Logeinträge</span>';
return;
}
el.innerHTML = logs.map(l => {
const t = fmtDate(l.timestamp, '');
const lvlClass = LOG_LEVELS.has(l.level) ? l.level : '';
return `<div class="log-entry"><span class="log-time">${esc(t)}</span><span class="log-level ${lvlClass}">${esc(l.level)}</span><span class="log-msg">${esc(l.message)}</span></div>`;
}).join('');
} catch (e) {
alert('Fehler: ' + e.message);
} }
el.innerHTML = logs.map(l => {
const t = l.timestamp ? new Date(l.timestamp).toLocaleString('de-DE') : '';
return `<div class="log-entry"><span class="log-time">${t}</span><span class="log-level ${l.level}">${l.level}</span><span class="log-msg">${esc(l.message)}</span></div>`;
}).join('');
} }
// Settings // Settings
let settingsCfg = null;
async function loadSettings() { async function loadSettings() {
const cfg = await api('/api/config'); let cfg;
try {
cfg = await api('/api/config');
} catch (e) {
alert('Fehler: ' + e.message);
return;
}
settingsCfg = cfg;
const secretPh = isSet => isSet ? '•••••••• (gespeichert)' : 'nicht gesetzt';
const el = document.getElementById('settingsForm'); const el = document.getElementById('settingsForm');
el.innerHTML = ` el.innerHTML = `
<div class="settings-section"> <div class="settings-section">
@@ -1403,11 +1551,11 @@ async function loadSettings() {
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Max. Wiederholungen</label> <label>Max. Wiederholungen</label>
<input type="number" id="cfg-max-retries" value="${cfg.recording?.max_retries ?? 5}"> <input type="number" id="cfg-max-retries" value="${esc(cfg.recording?.max_retries ?? 5)}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Retry-Delay (Sek.)</label> <label>Retry-Delay (Sek.)</label>
<input type="number" id="cfg-retry-delay" value="${cfg.recording?.retry_delay ?? 5}"> <input type="number" id="cfg-retry-delay" value="${esc(cfg.recording?.retry_delay ?? 5)}">
</div> </div>
</div> </div>
</div> </div>
@@ -1426,7 +1574,7 @@ async function loadSettings() {
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Passwort</label> <label>Passwort</label>
<input type="password" id="cfg-auth-pass" placeholder="***"> <input type="password" id="cfg-auth-pass" autocomplete="new-password" placeholder="${esc(secretPh(cfg.auth?.password_set))}">
</div> </div>
</div> </div>
</div> </div>
@@ -1435,11 +1583,11 @@ async function loadSettings() {
<div class="form-grid"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label>Standard-Vorlaufzeit (Min.)</label> <label>Standard-Vorlaufzeit (Min.)</label>
<input type="number" id="cfg-pre-buffer" min="1" max="120" value="${cfg.schedule?.default_pre_buffer ?? 5}"> <input type="number" id="cfg-pre-buffer" min="1" max="120" value="${esc(cfg.schedule?.default_pre_buffer ?? 5)}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Standard-Nachlaufzeit (Min.)</label> <label>Standard-Nachlaufzeit (Min.)</label>
<input type="number" id="cfg-post-buffer" min="1" max="120" value="${cfg.schedule?.default_post_buffer ?? 5}"> <input type="number" id="cfg-post-buffer" min="1" max="120" value="${esc(cfg.schedule?.default_post_buffer ?? 5)}">
</div> </div>
</div> </div>
</div> </div>
@@ -1452,7 +1600,11 @@ async function loadSettings() {
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Token</label> <label>Token</label>
<input type="text" id="cfg-ntfy-token" value="${esc(cfg.ntfy?.token || '')}"> <input type="password" id="cfg-ntfy-token" autocomplete="new-password" placeholder="${esc(secretPh(cfg.ntfy?.token_set))}">
${cfg.ntfy?.token_set ? `<div class="checkbox-group" style="margin-top:6px">
<input type="checkbox" id="cfg-ntfy-token-clear">
<label for="cfg-ntfy-token-clear" style="margin:0">Token entfernen</label>
</div>` : ''}
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Events (kommasepariert)</label> <label>Events (kommasepariert)</label>
@@ -1469,7 +1621,11 @@ async function loadSettings() {
</div> </div>
<div class="form-group"> <div class="form-group">
<label>API Key</label> <label>API Key</label>
<input type="text" id="cfg-plik-key" value="${esc(cfg.plik?.api_key || '')}"> <input type="password" id="cfg-plik-key" autocomplete="new-password" placeholder="${esc(secretPh(cfg.plik?.api_key_set))}">
${cfg.plik?.api_key_set ? `<div class="checkbox-group" style="margin-top:6px">
<input type="checkbox" id="cfg-plik-key-clear">
<label for="cfg-plik-key-clear" style="margin:0">API-Key entfernen</label>
</div>` : ''}
</div> </div>
<div class="form-group"> <div class="form-group">
<label>TTL</label> <label>TTL</label>
@@ -1484,42 +1640,68 @@ async function loadSettings() {
} }
async function saveSettings() { async function saveSettings() {
const prev = settingsCfg || {};
const passVal = document.getElementById('cfg-auth-pass').value; const passVal = document.getElementById('cfg-auth-pass').value;
const authData = { const authData = {
enabled: document.getElementById('cfg-auth-enabled').checked, enabled: document.getElementById('cfg-auth-enabled').checked,
username: document.getElementById('cfg-auth-user').value, username: document.getElementById('cfg-auth-user').value,
}; };
if (passVal && passVal !== '***') authData.password = passVal; if (passVal && passVal.length < 8) {
alert('Bitte ein Passwort (mind. 8 Zeichen) setzen');
return;
}
if (authData.enabled && !prev.auth?.password_set && !passVal) {
alert('Bitte ein Passwort (mind. 8 Zeichen) setzen');
return;
}
if (passVal) authData.password = passVal;
const authChanged = authData.enabled !== !!prev.auth?.enabled
|| authData.username !== (prev.auth?.username || '')
|| !!passVal;
window._defaultPreBuffer = parseInt(document.getElementById('cfg-pre-buffer').value) || 5; const ntfyData = {
window._defaultPostBuffer = parseInt(document.getElementById('cfg-post-buffer').value) || 5; url: document.getElementById('cfg-ntfy-url').value,
events: document.getElementById('cfg-ntfy-events').value,
};
const ntfyToken = document.getElementById('cfg-ntfy-token').value;
const ntfyClear = document.getElementById('cfg-ntfy-token-clear');
if (ntfyClear && ntfyClear.checked) ntfyData.token = '';
else if (ntfyToken) ntfyData.token = ntfyToken;
const plikData = {
url: document.getElementById('cfg-plik-url').value,
ttl: document.getElementById('cfg-plik-ttl').value,
};
const plikKey = document.getElementById('cfg-plik-key').value;
const plikClear = document.getElementById('cfg-plik-key-clear');
if (plikClear && plikClear.checked) plikData.api_key = '';
else if (plikKey) plikData.api_key = plikKey;
const data = { const data = {
recording: { recording: {
download_path: document.getElementById('cfg-download-path').value, download_path: document.getElementById('cfg-download-path').value,
max_retries: parseInt(document.getElementById('cfg-max-retries').value) || 5, max_retries: intOr(document.getElementById('cfg-max-retries').value, 5),
retry_delay: parseInt(document.getElementById('cfg-retry-delay').value) || 5, retry_delay: intOr(document.getElementById('cfg-retry-delay').value, 5),
}, },
schedule: { schedule: {
default_pre_buffer: parseInt(document.getElementById('cfg-pre-buffer').value) || 5, default_pre_buffer: intOr(document.getElementById('cfg-pre-buffer').value, 5),
default_post_buffer: parseInt(document.getElementById('cfg-post-buffer').value) || 5, default_post_buffer: intOr(document.getElementById('cfg-post-buffer').value, 5),
}, },
auth: authData, auth: authData,
ntfy: { ntfy: ntfyData,
url: document.getElementById('cfg-ntfy-url').value, plik: plikData,
token: document.getElementById('cfg-ntfy-token').value,
events: document.getElementById('cfg-ntfy-events').value,
},
plik: {
url: document.getElementById('cfg-plik-url').value,
api_key: document.getElementById('cfg-plik-key').value,
ttl: document.getElementById('cfg-plik-ttl').value,
},
}; };
try { try {
await api('/api/config', { method: 'PUT', body: JSON.stringify({ data }) }); await api('/api/config', { method: 'PUT', body: JSON.stringify({ data }) });
alert('Einstellungen gespeichert'); window._defaultPreBuffer = data.schedule.default_pre_buffer;
window._defaultPostBuffer = data.schedule.default_post_buffer;
let msg = 'Einstellungen gespeichert';
if (authData.enabled && authChanged) {
msg += '\n\nBei aktivierter Anmeldung fragt der Browser beim nächsten Laden nach Benutzername/Passwort';
}
alert(msg);
loadSettings();
} catch (e) { } catch (e) {
alert('Fehler: ' + e.message); alert('Fehler: ' + e.message);
} }
@@ -1528,7 +1710,28 @@ async function saveSettings() {
// Helpers // Helpers
function openModal(id) { document.getElementById(id).classList.add('active'); } function openModal(id) { document.getElementById(id).classList.add('active'); }
function closeModal(id) { document.getElementById(id).classList.remove('active'); } function closeModal(id) { document.getElementById(id).classList.remove('active'); }
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; } function intOr(v, fallback) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : fallback; }
function esc(s) {
if (s === null || s === undefined) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function intId(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isInteger(n) ? n : null;
}
const STATUS_CLASSES = new Set(['running', 'starting', 'stopped', 'completed', 'error']);
function statusClass(status) {
return STATUS_CLASSES.has(status) ? 'badge-' + status : 'badge-stopped';
}
function fmtDate(s, fallback) {
return s ? new Date(s).toLocaleString('de-DE') : fallback;
}
// Close modals on overlay click // Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => { document.querySelectorAll('.modal-overlay').forEach(overlay => {
@@ -1537,6 +1740,13 @@ document.querySelectorAll('.modal-overlay').forEach(overlay => {
}); });
}); });
// Close open modal on Escape
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(o => o.classList.remove('active'));
}
});
// Close burger menu on outside click // Close burger menu on outside click
document.addEventListener('click', e => { document.addEventListener('click', e => {
const tabs = document.getElementById('navTabs'); const tabs = document.getElementById('navTabs');
@@ -1559,7 +1769,10 @@ async function loadDefaults() {
loadStationOptions(); loadStationOptions();
loadDefaults(); loadDefaults();
loadDashboard(); loadDashboard();
setInterval(loadDashboard, 5000); setInterval(pollDashboard, 5000);
document.addEventListener('visibilitychange', () => {
if (!document.hidden && !authLost) loadDashboard();
});
api('/api/health').then(h => { api('/api/health').then(h => {
document.getElementById('footerVersion').textContent = 'Stream-Recorder v' + h.version; document.getElementById('footerVersion').textContent = 'Stream-Recorder v' + h.version;
+26 -2
View File
@@ -4,16 +4,40 @@ services:
container_name: stream-recorder container_name: stream-recorder
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8484:8484" # Standardmäßig nur lokal erreichbar. Für Zugriff von außen einen
# Reverse-Proxy (TLS) davorschalten oder gezielt an eine IP binden,
# z.B. eine Tailscale-/VPN-IP: "100.64.0.10:8484:8484"
- "127.0.0.1:8484:8484"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
# Optional: Recordings auf separatem Pfad (dann download_path in Einstellungen anpassen) # Optional: Recordings auf separatem Pfad (dann download_path in Einstellungen anpassen).
# Der Pfad muss in RECORDING_ROOTS enthalten sein (Standard: /app/data/recordings:/recordings).
# - /mnt/storage/recordings:/recordings # - /mnt/storage/recordings:/recordings
networks: networks:
stream-recorder_net: stream-recorder_net:
ipv4_address: 172.16.84.10 ipv4_address: 172.16.84.10
environment: environment:
TZ: Europe/Berlin TZ: Europe/Berlin
# UID/GID, unter der die Anwendung läuft (Besitzer von ./data)
# PUID: 1000
# PGID: 1000
# Erlaubte Basisverzeichnisse für download_path (durch ":" getrennt)
# RECORDING_ROOTS: /app/data/recordings:/recordings
# Logs älter als X Tage werden gelöscht
# LOG_RETENTION_DAYS: 90
# Swagger-UI unter /docs aktivieren
# ENABLE_API_DOCS: 1
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
# Benötigt vom Entrypoint (chown der Daten, Wechsel auf PUID/PGID)
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETUID
- SETGID
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8484/api/health"] test: ["CMD", "curl", "-f", "http://localhost:8484/api/health"]
interval: 30s interval: 30s
+56 -8
View File
@@ -2,6 +2,8 @@
Alle Funktionen des Web-UI sind auch per REST-API verfügbar. Basis-URL: `http://<host>:8484` Alle Funktionen des Web-UI sind auch per REST-API verfügbar. Basis-URL: `http://<host>:8484`
Die interaktive API-Dokumentation (Swagger-UI unter `/docs`) ist standardmäßig deaktiviert und kann mit der Umgebungsvariable `ENABLE_API_DOCS=1` eingeschaltet werden.
## Endpunkte ## Endpunkte
### System ### System
@@ -9,8 +11,8 @@ Alle Funktionen des Web-UI sind auch per REST-API verfügbar. Basis-URL: `http:/
| Methode | Pfad | Beschreibung | | Methode | Pfad | Beschreibung |
|---------|------|-------------| |---------|------|-------------|
| `GET` | `/api/health` | Health Check (kein Auth nötig) | | `GET` | `/api/health` | Health Check (kein Auth nötig) |
| `GET` | `/api/config` | Konfiguration lesen | | `GET` | `/api/config` | Konfiguration lesen (ohne Secrets) |
| `PUT` | `/api/config` | Konfiguration speichern | | `PUT` | `/api/config` | Konfiguration speichern (siehe [Konfiguration](#konfiguration)) |
### Jobs ### Jobs
@@ -20,8 +22,8 @@ Alle Funktionen des Web-UI sind auch per REST-API verfügbar. Basis-URL: `http:/
| `POST` | `/api/jobs` | Neuen Job erstellen | | `POST` | `/api/jobs` | Neuen Job erstellen |
| `GET` | `/api/jobs/:id` | Job-Details abrufen | | `GET` | `/api/jobs/:id` | Job-Details abrufen |
| `PUT` | `/api/jobs/:id` | Job aktualisieren | | `PUT` | `/api/jobs/:id` | Job aktualisieren |
| `DELETE` | `/api/jobs/:id` | Job löschen | | `DELETE` | `/api/jobs/:id` | Job löschen (nicht während einer laufenden Aufnahme → 400) |
| `POST` | `/api/jobs/:id/record` | Job-Aufnahme starten | | `POST` | `/api/jobs/:id/record` | Job-Aufnahme starten (läuft bereits → 409) |
### Senderbibliothek ### Senderbibliothek
@@ -31,7 +33,7 @@ Alle Funktionen des Web-UI sind auch per REST-API verfügbar. Basis-URL: `http:/
| `POST` | `/api/stations` | Neuen Sender erstellen | | `POST` | `/api/stations` | Neuen Sender erstellen |
| `GET` | `/api/stations/:id` | Sender-Details abrufen | | `GET` | `/api/stations/:id` | Sender-Details abrufen |
| `PUT` | `/api/stations/:id` | Sender aktualisieren | | `PUT` | `/api/stations/:id` | Sender aktualisieren |
| `DELETE` | `/api/stations/:id` | Sender löschen | | `DELETE` | `/api/stations/:id` | Sender löschen (verknüpfte Jobs werden gelöst) |
### Schnellaufnahme ### Schnellaufnahme
@@ -48,6 +50,8 @@ Alle Funktionen des Web-UI sind auch per REST-API verfügbar. Basis-URL: `http:/
} }
``` ```
Erlaubte URL-Schemata: `http`, `https`, `rtmp`, `rtmps`, `rtsp`, `rtsps`. Für Job-Felder gelten dieselben Prüfungen wie im Web-UI (siehe [Jobs](jobs.md#validierung)).
### Aufnahmen ### Aufnahmen
| Methode | Pfad | Beschreibung | | Methode | Pfad | Beschreibung |
@@ -55,7 +59,7 @@ Alle Funktionen des Web-UI sind auch per REST-API verfügbar. Basis-URL: `http:/
| `GET` | `/api/recordings` | Alle Aufnahmen (Parameter: `active_only`, `limit`) | | `GET` | `/api/recordings` | Alle Aufnahmen (Parameter: `active_only`, `limit`) |
| `GET` | `/api/recordings/:id` | Aufnahme-Status | | `GET` | `/api/recordings/:id` | Aufnahme-Status |
| `POST` | `/api/recordings/:id/stop` | Aufnahme stoppen | | `POST` | `/api/recordings/:id/stop` | Aufnahme stoppen |
| `POST` | `/api/recordings/:id/extend?minutes=30` | Aufnahme verlängern | | `POST` | `/api/recordings/:id/extend?minutes=30` | Aufnahme verlängern (`minutes` 1–1440; bei unbegrenzter Aufnahme → 400) |
| `POST` | `/api/recordings/:id/unlimited` | Zeitlimit entfernen | | `POST` | `/api/recordings/:id/unlimited` | Zeitlimit entfernen |
### Logs ### Logs
@@ -64,12 +68,56 @@ Alle Funktionen des Web-UI sind auch per REST-API verfügbar. Basis-URL: `http:/
|---------|------|-------------| |---------|------|-------------|
| `GET` | `/api/logs` | Protokoll (Parameter: `limit`, `level`) | | `GET` | `/api/logs` | Protokoll (Parameter: `limit`, `level`) |
Zeitstempel in API-Antworten sind UTC mit Offset, z.B. `2026-10-11T14:30:00+00:00`.
## Konfiguration
`GET /api/config` liefert keine Secrets. Stattdessen zeigen boolesche Felder an, ob ein Wert gesetzt ist:
| Feld | Bedeutung |
|------|-----------|
| `auth.password_set` | Passwort ist gesetzt |
| `ntfy.token_set` | NTFY-Token ist gesetzt |
| `plik.api_key_set` | Plik-API-Key ist gesetzt |
Regeln für `PUT /api/config`:
- Secret-Felder (`auth.password`, `ntfy.token`, `plik.api_key`) weglassen = unverändert
- Leerer String `""` = Secret entfernen (nicht beim Passwort)
- `auth.enabled: true` erfordert ein Passwort (mind. 8 Zeichen)
- `download_path` muss innerhalb von `RECORDING_ROOTS` liegen
- Unbekannte Schlüssel → 422
- Der Abschnitt `server` kann nicht über die API geändert werden
```bash
curl -u admin:passwort -X PUT http://localhost:8484/api/config -H "Content-Type: application/json" -d '{"ntfy": {"url": "https://ntfy.sh/mein-topic", "events": "start,error"}}'
```
## Authentifizierung ## Authentifizierung
Bei aktiviertem Auth wird Basic Authentication verwendet: Bei aktiviertem Auth (Standard bei Neuinstallationen) wird Basic Authentication verwendet:
```bash ```bash
curl -u admin:passwort http://localhost:8484/api/jobs curl -u admin:passwort http://localhost:8484/api/jobs
``` ```
Der Health-Endpunkt (`/api/health`) ist immer ohne Auth erreichbar. - Der Health-Endpunkt (`/api/health`) ist immer ohne Auth erreichbar.
- Nach 10 fehlgeschlagenen Logins innerhalb von 5 Minuten antwortet der Server für diese IP bis zum Ende des Zeitfensters mit 429.
## Browser-Zugriff
CORS ist nicht aktiviert. Schreibende Requests (`POST`, `PUT`, `DELETE`) mit fremdem `Origin` werden mit 403 abgewiesen. Die API ist damit aus Skripten/curl (ohne `Origin`-Header) und aus dem eigenen Web-UI nutzbar, nicht aber von fremden Webseiten.
## Fehlercodes
Fehler werden als JSON mit Feld `detail` zurückgegeben.
| Code | Bedeutung |
|------|-----------|
| `400` | Ungültige Anfrage, Meldung auf Deutsch (z.B. nicht erlaubte ffmpeg-Argumente, unzulässiger Pfad) |
| `401` | Anmeldung erforderlich oder fehlgeschlagen |
| `403` | Cross-Site-Request abgewiesen |
| `404` | Objekt nicht gefunden |
| `409` | Konflikt, z.B. Job nimmt bereits auf |
| `422` | Validierungsfehler (ungültige Felder, unbekannte Schlüssel) |
| `429` | Zu viele fehlgeschlagene Logins |
+8 -2
View File
@@ -27,9 +27,11 @@ Unterhalb der aktiven Aufnahmen zeigt das Dashboard die letzten abgeschlossenen
Laufende Aufnahmen können verlängert werden: Laufende Aufnahmen können verlängert werden:
- **+15 / +30 / +60 / +120 Minuten** — Schnellauswahl - **+15 / +30 / +60 / +120 Minuten** — Schnellauswahl
- **Eigene Dauer** — Beliebige Minutenzahl eingeben - **Eigene Dauer** — Minutenzahl zwischen 1 und 1440 eingeben
- **Unbegrenzt** — Zeitlimit komplett entfernen - **Unbegrenzt** — Zeitlimit komplett entfernen
Eine bereits unbegrenzte Aufnahme kann nicht weiter verlängert werden.
## Aufnahme stoppen ## Aufnahme stoppen
Über den **Stoppen**-Button im Dashboard. Die Aufnahme wird sauber beendet (SIGINT → ffmpeg finalisiert die Datei). Über den **Stoppen**-Button im Dashboard. Die Aufnahme wird sauber beendet (SIGINT → ffmpeg finalisiert die Datei).
@@ -42,6 +44,10 @@ Bei Verbindungsabbrüchen versucht der Recorder automatisch, die Verbindung wied
Bei jedem Reconnect wird eine neue Datei angelegt, um Datenverlust zu vermeiden. Bei jedem Reconnect wird eine neue Datei angelegt, um Datenverlust zu vermeiden.
## Neustart
Aufnahmen, die beim Neustart des Containers liefen, werden beim nächsten Start mit dem Fehler „Abgebrochen durch Neustart" markiert. Geplante Jobs starten wieder regulär nach Zeitplan.
## Segment-Splitting ## Segment-Splitting
Lange Aufnahmen können automatisch in Teile zerlegt werden. Die Segment-Dauer wird pro Job in Minuten konfiguriert. Lange Aufnahmen können automatisch in Teile zerlegt werden. Die Segment-Dauer wird pro Job in Minuten konfiguriert.
@@ -69,4 +75,4 @@ Kann pro Job manuell überschrieben werden (z.B. `mkv`, `ts`).
## Speicherort ## Speicherort
Aufnahmen werden unter `data/recordings/<job-name>/` gespeichert (im Container: `/app/data/recordings/`). Der Pfad ist über `recording.download_path` konfigurierbar. Aufnahmen werden unter `data/recordings/<job-name>/` gespeichert (im Container: `/app/data/recordings/`). Der Pfad ist über `recording.download_path` konfigurierbar und muss innerhalb eines Verzeichnisses aus `RECORDING_ROOTS` liegen (Standard: `/app/data/recordings:/recordings`), siehe [Konfiguration](konfiguration.md#umgebungsvariablen).
+35 -3
View File
@@ -13,7 +13,7 @@ Im Web-UI unter **Jobs → + Neuer Job**.
| Feld | Beschreibung | | Feld | Beschreibung |
|------|-------------| |------|-------------|
| **Name** | Eindeutiger Name (wird als Ordnername verwendet) | | **Name** | Eindeutiger Name (wird als Ordnername verwendet) |
| **Stream-URL** | URL des Streams (RTMP, HLS, HTTP, etc.) | | **Stream-URL** | URL des Streams. Erlaubt: `http`, `https`, `rtmp`, `rtmps`, `rtsp`, `rtsps` |
### Optionale Felder ### Optionale Felder
@@ -25,7 +25,39 @@ Im Web-UI unter **Jobs → + Neuer Job**.
| **Ausgabeformat** | Dateiendung. Leer = automatisch (mp4 für Video, mp3 für Audio) | | **Ausgabeformat** | Dateiendung. Leer = automatisch (mp4 für Video, mp3 für Audio) |
| **Max. Dauer** | Zeitlimit in Minuten. Entfällt bei Zeitplan mit Endzeit | | **Max. Dauer** | Zeitlimit in Minuten. Entfällt bei Zeitplan mit Endzeit |
| **Segment-Dauer** | Aufnahme in Teile zerlegen (Minuten pro Segment) | | **Segment-Dauer** | Aufnahme in Teile zerlegen (Minuten pro Segment) |
| **Extra ffmpeg Argumente** | Zusätzliche ffmpeg-Parameter (z.B. `-b:a 192k`) | | **Extra ffmpeg Argumente** | Zusätzliche ffmpeg-Parameter (z.B. `-b:a 192k`), siehe [Extra ffmpeg-Argumente](#extra-ffmpeg-argumente) |
## Extra ffmpeg-Argumente
Aus Sicherheitsgründen sind nur bestimmte ffmpeg-Optionen erlaubt. Alles andere wird mit einer Fehlermeldung abgelehnt.
| Art | Erlaubte Optionen |
|-----|-------------------|
| Ohne Wert | `-vn` `-an` `-sn` `-dn` `-shortest` `-copyts` |
| Mit einem Wert | `-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` |
Audiofilter (`-af`, `-filter:a`) dürfen keine Quellen wie `movie`, `amovie`, `sendcmd`, `zmq` oder Dateien verwenden. ffmpeg läuft zudem mit einer Protokoll-Whitelist, lokale Dateien können nicht als Eingabe gelesen werden.
Beispiele:
```
-b:a 192k -ar 44100
-c:a libmp3lame -q:a 2
-af loudnorm -metadata title="Meine Sendung"
```
## Validierung
| Feld | Regel |
|------|-------|
| **Startzeit / Endzeit** | Format `HH:MM` |
| **Startdatum / Enddatum** | Format `YYYY-MM-DD` |
| **Tage** | `*` oder Liste/Bereiche wie `Mo-Fr,So` (siehe [Tage-Syntax](#tage-syntax)) |
| **Vor-/Nachlaufzeit** | 0–120 Minuten |
| **Segment-Dauer** | 10 Sekunden bis 24 Stunden |
| **Max. Dauer** | bis 30 Tage |
Ungültige Eingaben werden mit einer Fehlermeldung abgelehnt (API: 400/422).
## Zeitplan ## Zeitplan
@@ -85,7 +117,7 @@ Die globale NTFY- und Plik-Konfiguration (URL, Token, etc.) erfolgt in den [Eins
## Manuelle Aufnahme ## Manuelle Aufnahme
Jobs können auch manuell über den **Aufnehmen**-Button gestartet werden, unabhängig vom Zeitplan. Jobs können auch manuell über den **Aufnehmen**-Button gestartet werden, unabhängig vom Zeitplan. Pro Job läuft höchstens eine Aufnahme gleichzeitig. Ein Job mit laufender Aufnahme kann nicht gelöscht werden.
## Schnellaufnahme ## Schnellaufnahme
+46 -6
View File
@@ -8,13 +8,15 @@ Die Konfiguration liegt in `data/config.yml` und kann über das Web-UI unter **E
Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine Vorlage mit Kommentaren findet sich in `bin/config.yml.dist`. Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine Vorlage mit Kommentaren findet sich in `bin/config.yml.dist`.
Die Datei enthält Secrets und wird beim Containerstart auf die Rechte `600` gesetzt.
## Abschnitte ## Abschnitte
### Aufnahme ### Aufnahme
| Parameter | Standard | Beschreibung | | Parameter | Standard | Beschreibung |
|-----------|----------|-------------| |-----------|----------|-------------|
| `recording.download_path` | `/app/data/recordings` | Speicherort für Aufnahmen | | `recording.download_path` | `/app/data/recordings` | Speicherort für Aufnahmen. Muss innerhalb eines Verzeichnisses aus `RECORDING_ROOTS` liegen |
| `recording.max_retries` | `5` | Max. Reconnect-Versuche bei Verbindungsabbruch (0 = unbegrenzt) | | `recording.max_retries` | `5` | Max. Reconnect-Versuche bei Verbindungsabbruch (0 = unbegrenzt) |
| `recording.retry_delay` | `5` | Wartezeit (Sekunden) zwischen Reconnect-Versuchen | | `recording.retry_delay` | `5` | Wartezeit (Sekunden) zwischen Reconnect-Versuchen |
@@ -25,13 +27,26 @@ Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine V
| `server.host` | `0.0.0.0` | Bind-Adresse des Webservers | | `server.host` | `0.0.0.0` | Bind-Adresse des Webservers |
| `server.port` | `8484` | Port des Webservers | | `server.port` | `8484` | Port des Webservers |
Der Abschnitt `server` wird nur aus der Datei gelesen und kann nicht über Web-UI/API geändert werden. Da die Anwendung nicht als root läuft, sind Ports unter 1024 nicht möglich.
### Authentifizierung ### Authentifizierung
| Parameter | Standard | Beschreibung | | Parameter | Standard | Beschreibung |
|-----------|----------|-------------| |-----------|----------|-------------|
| `auth.enabled` | `false` | Basic Auth aktivieren | | `auth.enabled` | `true` | Basic Auth aktivieren (erfordert ein Passwort mit mind. 8 Zeichen) |
| `auth.username` | `admin` | Benutzername | | `auth.username` | `admin` | Benutzername |
| `auth.password` | `stream-recorder` | Passwort | | `auth.password_hash` | _(generiert)_ | PBKDF2-Hash des Passworts |
| `auth.password` | — | Optional: Klartext-Passwort, wird beim Start automatisch in `password_hash` umgewandelt |
Beim ersten Start (keine `config.yml` vorhanden) ist die Anmeldung aktiv. Benutzer ist `admin`, das zufällig erzeugte Passwort wird einmalig im Container-Log ausgegeben:
```bash
docker logs stream-recorder
```
Bestehende Installationen behalten ihre bisherige Auth-Einstellung. Nach 10 fehlgeschlagenen Logins innerhalb von 5 Minuten wird die IP für den Rest des Zeitfensters mit HTTP 429 abgewiesen.
**Passwort vergessen:** In `data/config.yml` den Eintrag `password_hash` durch `password: neues-passwort` ersetzen und den Container neu starten.
### Zeitplan-Puffer ### Zeitplan-Puffer
@@ -47,7 +62,7 @@ Standardwerte für die Vor- und Nachlaufzeit bei neuen Jobs mit Zeitplan.
| Parameter | Standard | Beschreibung | | Parameter | Standard | Beschreibung |
|-----------|----------|-------------| |-----------|----------|-------------|
| `ntfy.url` | _(leer)_ | NTFY Topic-URL (z.B. `https://ntfy.sh/mein-topic`) | | `ntfy.url` | _(leer)_ | NTFY Topic-URL (z.B. `https://ntfy.sh/mein-topic`) |
| `ntfy.token` | _(leer)_ | Bearer-Token für authentifizierte Topics | | `ntfy.token` | _(leer)_ | Bearer-Token für authentifizierte Topics (wird von der API nie zurückgegeben) |
| `ntfy.events` | `error` | Kommaseparierte Events: `start`, `stop`, `error`, `upload` | | `ntfy.events` | `error` | Kommaseparierte Events: `start`, `stop`, `error`, `upload` |
### Plik ### Plik
@@ -55,7 +70,7 @@ Standardwerte für die Vor- und Nachlaufzeit bei neuen Jobs mit Zeitplan.
| Parameter | Standard | Beschreibung | | Parameter | Standard | Beschreibung |
|-----------|----------|-------------| |-----------|----------|-------------|
| `plik.url` | _(leer)_ | Plik-Server URL (z.B. `https://plik.example.com`) | | `plik.url` | _(leer)_ | Plik-Server URL (z.B. `https://plik.example.com`) |
| `plik.api_key` | _(leer)_ | API Key für authentifizierte Uploads | | `plik.api_key` | _(leer)_ | API Key für authentifizierte Uploads (wird von der API nie zurückgegeben) |
| `plik.ttl` | `30d` | Aufbewahrungsdauer der Uploads (`30d`, `12h`, etc.) | | `plik.ttl` | `30d` | Aufbewahrungsdauer der Uploads (`30d`, `12h`, etc.) |
### Logging ### Logging
@@ -64,6 +79,31 @@ Standardwerte für die Vor- und Nachlaufzeit bei neuen Jobs mit Zeitplan.
|-----------|----------|-------------| |-----------|----------|-------------|
| `logging.level` | `INFO` | Log-Level: `DEBUG`, `INFO`, `WARN`, `ERROR` | | `logging.level` | `INFO` | Log-Level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
Anwendungs-Logs erscheinen im Web-UI und zusätzlich auf stdout (`docker logs stream-recorder`). Einträge älter als `LOG_RETENTION_DAYS` Tage werden automatisch gelöscht.
## Umgebungsvariablen
| Variable | Standard | Beschreibung |
|----------|----------|-------------|
| `TZ` | — | Zeitzone (z.B. `Europe/Berlin`) |
| `PUID` | `1000` | UID, unter der die Anwendung läuft |
| `PGID` | `1000` | GID, unter der die Anwendung läuft |
| `RECORDING_ROOTS` | `/app/data/recordings:/recordings` | Erlaubte Basisverzeichnisse für `download_path` (durch `:` getrennt) |
| `LOG_RETENTION_DAYS` | `90` | Aufbewahrung der Log-Einträge in Tagen |
| `ENABLE_API_DOCS` | — | `1` aktiviert die Swagger-UI unter `/docs` |
Der Container startet als root, passt UID/GID des internen Users `app` an `PUID`/`PGID` an, setzt den Besitzer von `data/` und den vorhandenen Verzeichnissen aus `RECORDING_ROOTS` (nur abweichende Dateien) und startet die Anwendung anschließend ohne Root-Rechte.
Eigener Aufnahmepfad, z.B. auf einem separaten Laufwerk:
```yaml
volumes:
- ./data:/app/data
- /mnt/storage/recordings:/recordings
```
Danach in den Einstellungen `download_path` auf `/recordings` setzen. Andere Pfade müssen zusätzlich in `RECORDING_ROOTS` eingetragen werden.
## Beispiel ## Beispiel
```yaml ```yaml
@@ -79,7 +119,7 @@ server:
auth: auth:
enabled: true enabled: true
username: admin username: admin
password: mein-passwort password: mein-passwort # wird beim Start in password_hash umgewandelt
schedule: schedule:
default_pre_buffer: 5 default_pre_buffer: 5
+4 -2
View File
@@ -23,7 +23,7 @@ Push-Nachrichten per [ntfy.sh](https://ntfy.sh) bei verschiedenen Events.
### Authentifizierung ### Authentifizierung
Für private Topics kann ein Bearer-Token in den Einstellungen hinterlegt werden. Für private Topics kann ein Bearer-Token in den Einstellungen hinterlegt werden. Der Token wird nach dem Speichern nicht mehr von der API ausgegeben.
--- ---
@@ -54,4 +54,6 @@ Die Plik-URL wird in der Datenbank gespeichert und im Dashboard bei den **Letzte
### Segment-Upload ### Segment-Upload
Bei Segment-Splitting werden alle Segmente einzeln hochgeladen. Die NTFY-Nachricht enthält die Anzahl der hochgeladenen Segmente. Bei Segment-Splitting werden nur die Segmente der jeweiligen Aufnahme einzeln hochgeladen. Wurde die Aufnahme nach Verbindungsabbrüchen fortgesetzt, werden alle Teile hochgeladen. Die NTFY-Nachricht enthält die Anzahl der hochgeladenen Segmente.
Der Plik-API-Key wird wie der NTFY-Token nach dem Speichern nicht mehr von der API ausgegeben.