Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aec64719f3 | ||
|
|
66edfbf8d9 | ||
|
|
65b0b5ac0f | ||
|
|
c3f68ace96 |
@@ -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
@@ -1,29 +1,46 @@
|
||||
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 && \
|
||||
apt-get install -y --no-install-recommends ffmpeg curl && \
|
||||
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
|
||||
|
||||
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/frontend/ /app/frontend/
|
||||
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
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV STATIC_DIR=/app/frontend/static
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PUID=1000 \
|
||||
PGID=1000 \
|
||||
RECORDING_ROOTS=/app/data/recordings:/recordings
|
||||
|
||||
EXPOSE 8484
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8484/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["python", "app.py"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
Release-Images werden automatisch in der Gitea Container Registry veröffentlicht:
|
||||
@@ -75,23 +83,64 @@ services:
|
||||
container_name: stream-recorder
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8484:8484"
|
||||
- "127.0.0.1:8484:8484"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
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
|
||||
|
||||
Alle persistenten Daten liegen in einem einzigen Volume (`./data`):
|
||||
|
||||
| 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/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
|
||||
|
||||
```bash
|
||||
|
||||
+613
-202
File diff suppressed because it is too large
Load Diff
+119
-19
@@ -1,9 +1,18 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
import yaml
|
||||
|
||||
DEFAULT_DOWNLOAD_PATH = "/app/data/recordings"
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"recording": {
|
||||
"download_path": "/app/data/recordings",
|
||||
"download_path": DEFAULT_DOWNLOAD_PATH,
|
||||
"max_retries": 5,
|
||||
"retry_delay": 5,
|
||||
},
|
||||
@@ -14,7 +23,7 @@ DEFAULT_CONFIG = {
|
||||
"auth": {
|
||||
"enabled": False,
|
||||
"username": "admin",
|
||||
"password": "stream-recorder",
|
||||
"password_hash": "",
|
||||
},
|
||||
"ntfy": {
|
||||
"url": "",
|
||||
@@ -35,35 +44,126 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
}
|
||||
|
||||
PBKDF2_ITERATIONS = 600000
|
||||
|
||||
|
||||
LEGACY_DEFAULT_PASSWORD = "stream-recorder"
|
||||
|
||||
|
||||
def hash_password(password: str, iterations: int | None = None) -> str:
|
||||
iterations = iterations or PBKDF2_ITERATIONS
|
||||
salt = secrets.token_bytes(16)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
||||
return "pbkdf2_sha256${}${}${}".format(
|
||||
iterations,
|
||||
base64.b64encode(salt).decode("ascii"),
|
||||
base64.b64encode(dk).decode("ascii"),
|
||||
)
|
||||
|
||||
|
||||
def verify_password(password: str, encoded: str) -> bool:
|
||||
try:
|
||||
algo, iterations, salt_b64, hash_b64 = encoded.split("$", 3)
|
||||
if algo != "pbkdf2_sha256":
|
||||
return False
|
||||
salt = base64.b64decode(salt_b64)
|
||||
expected = base64.b64decode(hash_b64)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations))
|
||||
return hmac.compare_digest(dk, expected)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def recording_roots() -> list[str]:
|
||||
raw = os.environ.get("RECORDING_ROOTS", os.pathsep.join(["/app/data/recordings", "/recordings"]))
|
||||
return [os.path.realpath(r) for r in raw.split(os.pathsep) if r.strip()]
|
||||
|
||||
|
||||
def validate_download_path(path: str) -> str:
|
||||
"""Gibt den normalisierten Pfad zurück oder wirft ValueError."""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError("Download-Pfad darf nicht leer sein")
|
||||
if "\x00" in path:
|
||||
raise ValueError("Download-Pfad enthält ungültige Zeichen")
|
||||
if not os.path.isabs(path):
|
||||
raise ValueError("Download-Pfad muss absolut sein")
|
||||
real = os.path.realpath(path)
|
||||
roots = recording_roots()
|
||||
for root in roots:
|
||||
if real == root or real.startswith(root.rstrip(os.sep) + os.sep):
|
||||
return real
|
||||
raise ValueError(f"Download-Pfad muss innerhalb von {', '.join(roots)} liegen")
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self, config_path: str = "/app/data/config.yml"):
|
||||
self.config_path = config_path
|
||||
self.data = {}
|
||||
self.lock = threading.RLock()
|
||||
self.generated_password = None
|
||||
self.password_migrated = False
|
||||
self.default_password_active = False
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
self.data = _deep_copy(DEFAULT_CONFIG)
|
||||
if os.path.exists(self.config_path):
|
||||
with open(self.config_path, "r") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
_deep_merge(self.data, user_config)
|
||||
else:
|
||||
self.save()
|
||||
with self.lock:
|
||||
self.data = _deep_copy(DEFAULT_CONFIG)
|
||||
if os.path.exists(self.config_path):
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
_deep_merge(self.data, user_config)
|
||||
auth = self.data.setdefault("auth", {})
|
||||
if "password" in auth:
|
||||
plain = auth.pop("password")
|
||||
if plain and not auth.get("password_hash"):
|
||||
auth["password_hash"] = hash_password(str(plain))
|
||||
self.password_migrated = True
|
||||
self.save()
|
||||
# Altes Standardpasswort gilt als nicht gesetzt, sonst würde
|
||||
# "Auth aktivieren" ohne neues Passwort ein bekanntes Passwort scharf schalten.
|
||||
pw_hash = auth.get("password_hash")
|
||||
if pw_hash and verify_password(LEGACY_DEFAULT_PASSWORD, pw_hash):
|
||||
if auth.get("enabled"):
|
||||
self.default_password_active = True
|
||||
else:
|
||||
auth["password_hash"] = ""
|
||||
self.save()
|
||||
else:
|
||||
password = secrets.token_urlsafe(12)
|
||||
self.data["auth"]["enabled"] = True
|
||||
self.data["auth"]["username"] = "admin"
|
||||
self.data["auth"]["password_hash"] = hash_password(password)
|
||||
self.generated_password = password
|
||||
self.save()
|
||||
|
||||
def save(self):
|
||||
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
||||
with open(self.config_path, "w") as f:
|
||||
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
with self.lock:
|
||||
directory = os.path.dirname(self.config_path) or "."
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=".config.", suffix=".tmp", dir=directory)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.chmod(tmp_path, 0o600)
|
||||
os.replace(tmp_path, self.config_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def get(self, *keys, default=None):
|
||||
val = self.data
|
||||
for key in keys:
|
||||
if isinstance(val, dict) and key in val:
|
||||
val = val[key]
|
||||
else:
|
||||
return default
|
||||
return val
|
||||
with self.lock:
|
||||
val = self.data
|
||||
for key in keys:
|
||||
if isinstance(val, dict) and key in val:
|
||||
val = val[key]
|
||||
else:
|
||||
return default
|
||||
return val
|
||||
|
||||
|
||||
def _deep_copy(d):
|
||||
|
||||
+56
-8
@@ -1,9 +1,25 @@
|
||||
import datetime
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Text, text
|
||||
import logging
|
||||
|
||||
from sqlalchemy import create_engine, event, Column, Integer, String, Boolean, DateTime, Text, text
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
logger = logging.getLogger("models")
|
||||
|
||||
|
||||
def utcnow() -> datetime.datetime:
|
||||
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def iso_utc(dt: datetime.datetime | None) -> str | None:
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
||||
return dt.astimezone(datetime.timezone.utc).isoformat()
|
||||
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
@@ -37,8 +53,8 @@ class Job(Base):
|
||||
|
||||
station_id = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class Station(Base):
|
||||
@@ -49,8 +65,8 @@ class Station(Base):
|
||||
stream_url = Column(String(1000), nullable=False)
|
||||
stream_type = Column(String(20), default="auto")
|
||||
description = Column(Text, default="")
|
||||
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class Recording(Base):
|
||||
@@ -65,7 +81,7 @@ class Recording(Base):
|
||||
output_file = Column(String(500), default="")
|
||||
pid = Column(Integer, nullable=True)
|
||||
status = Column(String(20), default="running") # running, stopped, completed, error
|
||||
started_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
started_at = Column(DateTime, default=utcnow)
|
||||
stopped_at = Column(DateTime, nullable=True)
|
||||
scheduled_stop = Column(DateTime, nullable=True)
|
||||
segment_mode = Column(Boolean, default=False)
|
||||
@@ -79,7 +95,7 @@ class LogEntry(Base):
|
||||
__tablename__ = "logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
timestamp = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
timestamp = Column(DateTime, default=utcnow)
|
||||
level = Column(String(10), default="INFO")
|
||||
message = Column(Text, nullable=False)
|
||||
job_name = Column(String(200), nullable=True)
|
||||
@@ -91,7 +107,20 @@ _SessionLocal = None
|
||||
|
||||
def init_db(db_path: str):
|
||||
global _engine, _SessionLocal
|
||||
_engine = create_engine(f"sqlite:///{db_path}", connect_args={"check_same_thread": False})
|
||||
_engine = create_engine(
|
||||
f"sqlite:///{db_path}",
|
||||
connect_args={"check_same_thread": False, "timeout": 30},
|
||||
)
|
||||
|
||||
@event.listens_for(_engine, "connect")
|
||||
def _set_sqlite_pragmas(dbapi_conn, _record):
|
||||
cur = dbapi_conn.cursor()
|
||||
try:
|
||||
cur.execute("PRAGMA journal_mode=WAL")
|
||||
cur.execute("PRAGMA busy_timeout=5000")
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
Base.metadata.create_all(_engine)
|
||||
_SessionLocal = sessionmaker(bind=_engine)
|
||||
_migrate(_engine)
|
||||
@@ -115,3 +144,22 @@ def _migrate(engine):
|
||||
|
||||
def get_session():
|
||||
return _SessionLocal()
|
||||
|
||||
|
||||
def prune_logs(days: int) -> int:
|
||||
if days <= 0:
|
||||
return 0
|
||||
cutoff = utcnow() - datetime.timedelta(days=days)
|
||||
session = get_session()
|
||||
try:
|
||||
count = session.query(LogEntry).filter(LogEntry.timestamp < cutoff).delete(synchronize_session=False)
|
||||
session.commit()
|
||||
if count:
|
||||
logger.info("%d alte Log-Einträge gelöscht (älter als %d Tage)", count, days)
|
||||
return count
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Log-Bereinigung fehlgeschlagen: %s", e)
|
||||
return 0
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
+28
-22
@@ -1,8 +1,12 @@
|
||||
"""NTFY push notification plugin."""
|
||||
|
||||
import subprocess
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("plugins.ntfy")
|
||||
|
||||
EVENT_TAGS = {
|
||||
"start": "red_circle",
|
||||
"stop": "white_check_mark",
|
||||
@@ -11,6 +15,23 @@ EVENT_TAGS = {
|
||||
}
|
||||
|
||||
|
||||
def _post(url: str, title: str, message: str, token: str, priority: str, tags: str):
|
||||
params = {"title": title, "priority": priority or "default"}
|
||||
if tags:
|
||||
params["tags"] = tags
|
||||
headers = {"Content-Type": "text/plain; charset=utf-8"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
try:
|
||||
resp = httpx.post(url, params=params, content=message.encode("utf-8"),
|
||||
headers=headers, timeout=10.0)
|
||||
if resp.status_code >= 400:
|
||||
logger.warning("NTFY-Benachrichtigung fehlgeschlagen: HTTP %s %s",
|
||||
resp.status_code, resp.text[:200])
|
||||
except Exception as e:
|
||||
logger.warning("NTFY-Benachrichtigung fehlgeschlagen: %s", e)
|
||||
|
||||
|
||||
def send_notification(
|
||||
url: str,
|
||||
title: str,
|
||||
@@ -21,26 +42,11 @@ def send_notification(
|
||||
):
|
||||
if not url:
|
||||
return
|
||||
|
||||
cmd = [
|
||||
"curl", "-s", "-o", "/dev/null", "--max-time", "10",
|
||||
"-H", f"Title: {title}",
|
||||
"-H", f"Priority: {priority}",
|
||||
]
|
||||
if tags:
|
||||
cmd += ["-H", f"Tags: {tags}"]
|
||||
cmd += ["-d", message]
|
||||
if token:
|
||||
cmd += ["-H", f"Authorization: Bearer {token}"]
|
||||
cmd.append(url)
|
||||
|
||||
def _send():
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, timeout=15, stdin=subprocess.DEVNULL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
threading.Thread(
|
||||
target=_post,
|
||||
args=(url, title, message, token, priority, tags),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
|
||||
class NtfyNotifier:
|
||||
@@ -53,7 +59,7 @@ class NtfyNotifier:
|
||||
url = self.config.get("ntfy", "url", default="")
|
||||
if not url:
|
||||
return False
|
||||
events = self.config.get("ntfy", "events", default="error")
|
||||
events = self.config.get("ntfy", "events", default="error") or ""
|
||||
return event in [e.strip() for e in events.split(",")]
|
||||
|
||||
def notify(self, event: str, title: str, message: str, job_ntfy_enabled: bool = False, priority: str = "default"):
|
||||
|
||||
+108
-97
@@ -3,12 +3,15 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Optional, Callable
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
|
||||
UPLOAD_TIMEOUT = httpx.Timeout(7200.0, connect=30.0)
|
||||
|
||||
|
||||
def _parse_ttl_seconds(ttl: str) -> Optional[int]:
|
||||
def _parse_ttl_seconds(ttl: str) -> int | None:
|
||||
if not ttl:
|
||||
return None
|
||||
m = re.match(r"^(\d+)d$", ttl)
|
||||
@@ -23,69 +26,88 @@ def _parse_ttl_seconds(ttl: str) -> Optional[int]:
|
||||
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]
|
||||
# Plik baut die URL aus DownloadDomain bzw. dem Host-Header. Ohne DownloadDomain
|
||||
# hinter einem Reverse-Proxy kommt dabei z.B. 127.0.0.1:3000 heraus, daher den
|
||||
# /file/-Pfad immer auf die konfigurierte Plik-URL umschreiben.
|
||||
idx = download_url.find("/file/")
|
||||
if idx != -1:
|
||||
file_path = download_url[idx + len("/file/"):]
|
||||
uid = file_path.split("/")[0]
|
||||
if uid:
|
||||
download_url = f"{plik_base}/file/{file_path}"
|
||||
return {"browser_url": f"{plik_base}/#/?id={uid}", "download_url": download_url}
|
||||
return {"browser_url": download_url, "download_url": download_url}
|
||||
return {"error": f"Antwort nicht lesbar: {body[:300]}"}
|
||||
|
||||
|
||||
def upload_file(
|
||||
file_path: str,
|
||||
plik_url: str,
|
||||
api_key: str = "",
|
||||
ttl: str = "30d",
|
||||
) -> Optional[dict]:
|
||||
"""Upload a file to Plik. Returns dict with 'browser_url' and 'download_url', or None on failure."""
|
||||
if not plik_url or not os.path.isfile(file_path):
|
||||
return None
|
||||
) -> dict | None:
|
||||
"""Upload a file to Plik. Returns dict with 'browser_url', 'download_url', or {'error': reason} on failure."""
|
||||
if not plik_url:
|
||||
return {"error": "Keine Plik-URL konfiguriert"}
|
||||
if not os.path.isfile(file_path):
|
||||
return {"error": f"Datei nicht gefunden: {file_path}"}
|
||||
if os.path.getsize(file_path) == 0:
|
||||
return None
|
||||
return {"error": "Datei ist leer"}
|
||||
|
||||
plik_base = plik_url.rstrip("/")
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
cmd = ["curl", "-sS", "--max-time", "7200"]
|
||||
headers = {}
|
||||
if api_key:
|
||||
cmd += ["-H", f"X-PlikToken: {api_key}"]
|
||||
|
||||
cmd += ["-F", f"file=@{file_path};filename={filename}"]
|
||||
headers["X-PlikToken"] = api_key
|
||||
|
||||
form = {}
|
||||
ttl_secs = _parse_ttl_seconds(ttl)
|
||||
if ttl_secs is not None:
|
||||
cmd += ["-F", f"ttl={ttl_secs}"]
|
||||
|
||||
cmd.append(plik_base)
|
||||
form["ttl"] = str(ttl_secs)
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=7200, stdin=subprocess.DEVNULL)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
upload_id = data.get("id", "")
|
||||
if not upload_id:
|
||||
return None
|
||||
|
||||
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 None
|
||||
except Exception:
|
||||
return None
|
||||
with open(file_path, "rb") as fh:
|
||||
resp = httpx.post(
|
||||
plik_base,
|
||||
headers=headers,
|
||||
data=form,
|
||||
files={"file": (filename, fh, "application/octet-stream")},
|
||||
timeout=UPLOAD_TIMEOUT,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
return {"error": f"HTTP {resp.status_code}: {resp.text.strip()[:500]}"}
|
||||
return _parse_response(resp.text, plik_base, filename)
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Upload-Timeout (max. 2h)"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
class PlikUploader:
|
||||
@@ -94,9 +116,8 @@ class PlikUploader:
|
||||
self._log = log_fn
|
||||
self._notify = notify_fn
|
||||
|
||||
def upload_recording(self, output_dir: str, output_file: str, job_name: str,
|
||||
segment_mode: bool, delete_after: bool, ntfy_enabled: bool,
|
||||
save_url_fn: Callable = None):
|
||||
def upload_recording(self, files: list[str], job_name: str, segment_mode: bool,
|
||||
delete_after: bool, ntfy_enabled: bool, save_url_fn: Callable = None):
|
||||
plik_url = self.config.get("plik", "url", default="")
|
||||
if not plik_url:
|
||||
self._log("WARN", f"Plik-Upload übersprungen: Keine URL konfiguriert [{job_name}]", job_name)
|
||||
@@ -104,71 +125,61 @@ class PlikUploader:
|
||||
|
||||
api_key = self.config.get("plik", "api_key", default="")
|
||||
ttl = self.config.get("plik", "ttl", default="30d")
|
||||
files = [f for f in files if not os.path.basename(f).startswith(".")]
|
||||
|
||||
def _do_upload():
|
||||
try:
|
||||
if segment_mode:
|
||||
self._upload_segments(output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn)
|
||||
else:
|
||||
self._upload_single(output_file, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn)
|
||||
self._upload_files(files, job_name, segment_mode, plik_url, api_key, ttl,
|
||||
delete_after, ntfy_enabled, save_url_fn)
|
||||
except Exception as e:
|
||||
self._log("ERROR", f"Plik-Upload Fehler: {e} [{job_name}]", job_name)
|
||||
|
||||
threading.Thread(target=_do_upload, daemon=True).start()
|
||||
|
||||
def _upload_single(self, file_path, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn=None):
|
||||
if not os.path.isfile(file_path) or os.path.getsize(file_path) == 0:
|
||||
def _remove(self, path: str, job_name: str):
|
||||
try:
|
||||
os.remove(path)
|
||||
self._log("INFO", f"Lokale Datei gelöscht: {os.path.basename(path)} [{job_name}]", job_name)
|
||||
except OSError as e:
|
||||
self._log("ERROR", f"Lokale Datei konnte nicht gelöscht werden: {path} - {e} [{job_name}]", job_name)
|
||||
|
||||
def _upload_files(self, files, job_name, segment_mode, plik_url, api_key, ttl,
|
||||
delete_after, ntfy_enabled, save_url_fn=None):
|
||||
files = [f for f in files if os.path.isfile(f) and os.path.getsize(f) > 0]
|
||||
if not files:
|
||||
self._log("WARN", f"Plik-Upload übersprungen: Datei fehlt oder leer [{job_name}]", job_name)
|
||||
return
|
||||
|
||||
self._log("INFO", f"Plik-Upload: {os.path.basename(file_path)} [{job_name}]", job_name)
|
||||
result = upload_file(file_path, plik_url, api_key, ttl)
|
||||
|
||||
if result:
|
||||
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 [{job_name}]", job_name)
|
||||
|
||||
def _upload_segments(self, output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn=None):
|
||||
if not os.path.isdir(output_dir):
|
||||
return
|
||||
|
||||
files = sorted(f for f in os.listdir(output_dir) if not f.startswith(".") and os.path.isfile(os.path.join(output_dir, f)))
|
||||
if not files:
|
||||
return
|
||||
|
||||
label = "Segment" if segment_mode else "Teil"
|
||||
multi = len(files) > 1
|
||||
uploaded = 0
|
||||
last_browser_url = None
|
||||
for fname in files:
|
||||
fpath = os.path.join(output_dir, fname)
|
||||
if os.path.getsize(fpath) == 0:
|
||||
continue
|
||||
self._log("INFO", f"Plik Segment-Upload: {fname} [{job_name}]", job_name)
|
||||
for fpath in files:
|
||||
fname = os.path.basename(fpath)
|
||||
prefix = f"Plik {label}-Upload" if multi else "Plik-Upload"
|
||||
self._log("INFO", f"{prefix}: {fname} [{job_name}]", job_name)
|
||||
result = upload_file(fpath, plik_url, api_key, ttl)
|
||||
if result:
|
||||
if result and "error" in result:
|
||||
self._log("ERROR", f"{prefix} fehlgeschlagen: {fname} - {result['error']} [{job_name}]", job_name)
|
||||
elif result and result.get("browser_url"):
|
||||
last_browser_url = result["browser_url"]
|
||||
self._log("INFO", f"Plik Segment hochgeladen: {last_browser_url} [{job_name}]", job_name)
|
||||
self._log("INFO", f"{prefix} abgeschlossen: {last_browser_url} [{job_name}]", job_name)
|
||||
if result.get("download_url"):
|
||||
self._log("INFO", f"Plik-Download: {result['download_url']} [{job_name}]", job_name)
|
||||
uploaded += 1
|
||||
if delete_after:
|
||||
os.remove(fpath)
|
||||
self._log("INFO", f"Segment gelöscht: {fname} [{job_name}]", job_name)
|
||||
self._remove(fpath, job_name)
|
||||
else:
|
||||
self._log("ERROR", f"Plik Segment-Upload fehlgeschlagen: {fname} [{job_name}]", job_name)
|
||||
self._log("ERROR", f"{prefix} fehlgeschlagen: {fname} (unbekannter Fehler) [{job_name}]", job_name)
|
||||
|
||||
if uploaded > 0:
|
||||
if save_url_fn and last_browser_url:
|
||||
save_url_fn(last_browser_url)
|
||||
if self._notify:
|
||||
msg = f"{job_name}: {uploaded} Segment(e) hochgeladen"
|
||||
if last_browser_url:
|
||||
msg += f"\n{last_browser_url}"
|
||||
if multi:
|
||||
msg = f"{job_name}: {uploaded} {label}(e) hochgeladen"
|
||||
if last_browser_url:
|
||||
msg += f"\n{last_browser_url}"
|
||||
else:
|
||||
msg = f"{job_name}: {last_browser_url}"
|
||||
self._notify("upload", "Plik-Upload", msg, ntfy_enabled)
|
||||
|
||||
+170
-44
@@ -1,16 +1,95 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import glob as globmod
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from models import Recording, get_session, LogEntry
|
||||
from config import DEFAULT_DOWNLOAD_PATH, validate_download_path
|
||||
from models import Recording, get_session, LogEntry, iso_utc, utcnow
|
||||
|
||||
logger = logging.getLogger("recorder")
|
||||
|
||||
ALLOWED_URL_SCHEMES = ("http", "https", "rtmp", "rtmps", "rtsp", "rtsps")
|
||||
|
||||
PROTOCOL_WHITELIST = "http,https,tcp,tls,crypto,hls,httpproxy,rtmp,rtmps,rtmpt,rtmpts,ffrtmphttp,rtp,udp"
|
||||
|
||||
FFMPEG_FLAGS = {"-vn", "-an", "-sn", "-dn", "-shortest", "-copyts"}
|
||||
FFMPEG_VALUE_OPTIONS = {
|
||||
"-c", "-c:a", "-c:v", "-codec", "-codec:a", "-codec:v", "-acodec", "-vcodec",
|
||||
"-b:a", "-b:v", "-ar", "-ac", "-map", "-metadata", "-metadata:s:a", "-t", "-to",
|
||||
"-q:a", "-aq", "-af", "-filter:a", "-bsf:a", "-bsf:v", "-f", "-movflags",
|
||||
"-segment_format", "-fflags", "-avoid_negative_ts", "-max_muxing_queue_size",
|
||||
}
|
||||
FORBIDDEN_FILTER_WORDS = ("movie", "sendcmd", "zmq", "file")
|
||||
MAX_FFMPEG_ARGS_LEN = 500
|
||||
MIN_STABLE_RUNTIME = 60
|
||||
|
||||
STALE_ERROR_MESSAGE = "Abgebrochen durch Neustart"
|
||||
|
||||
|
||||
def validate_ffmpeg_args(args: str) -> list[str]:
|
||||
"""Prüft zusätzliche ffmpeg-Argumente gegen eine Allowlist. Wirft ValueError."""
|
||||
if not args:
|
||||
return []
|
||||
if len(args) > MAX_FFMPEG_ARGS_LEN:
|
||||
raise ValueError(f"Zusätzliche ffmpeg-Argumente sind zu lang (max. {MAX_FFMPEG_ARGS_LEN} Zeichen)")
|
||||
try:
|
||||
tokens = shlex.split(args)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Zusätzliche ffmpeg-Argumente nicht lesbar: {e}")
|
||||
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
tok = tokens[i]
|
||||
if tok in FFMPEG_FLAGS:
|
||||
i += 1
|
||||
continue
|
||||
if tok in FFMPEG_VALUE_OPTIONS:
|
||||
if i + 1 >= len(tokens):
|
||||
raise ValueError(f"ffmpeg-Option '{tok}' benötigt einen Wert")
|
||||
val = tokens[i + 1]
|
||||
if not val or val.startswith("-"):
|
||||
raise ValueError(f"Ungültiger Wert für ffmpeg-Option '{tok}': '{val}'")
|
||||
if tok in ("-af", "-filter:a"):
|
||||
low = val.lower()
|
||||
for word in FORBIDDEN_FILTER_WORDS:
|
||||
if word in low:
|
||||
raise ValueError(f"Filter '{val}' ist nicht erlaubt ('{word}')")
|
||||
i += 2
|
||||
continue
|
||||
raise ValueError(f"ffmpeg-Argument nicht erlaubt: '{tok}'")
|
||||
return tokens
|
||||
|
||||
|
||||
def validate_stream_url(url: str) -> str:
|
||||
"""Prüft eine Stream-URL. Wirft ValueError."""
|
||||
if not isinstance(url, str):
|
||||
raise ValueError("Stream-URL fehlt")
|
||||
url = url.strip()
|
||||
if not url:
|
||||
raise ValueError("Stream-URL fehlt")
|
||||
if len(url) > 1000:
|
||||
raise ValueError("Stream-URL ist zu lang (max. 1000 Zeichen)")
|
||||
if any(ch.isspace() or ord(ch) < 32 or ord(ch) == 127 for ch in url):
|
||||
raise ValueError("Stream-URL enthält Leer- oder Steuerzeichen")
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
hostname = parts.hostname
|
||||
except ValueError:
|
||||
raise ValueError("Stream-URL ist ungültig")
|
||||
if parts.scheme.lower() not in ALLOWED_URL_SCHEMES:
|
||||
raise ValueError(f"Stream-URL muss mit {', '.join(s + '://' for s in ALLOWED_URL_SCHEMES)} beginnen")
|
||||
if not parts.netloc or not hostname:
|
||||
raise ValueError("Stream-URL enthält keinen Host")
|
||||
return url
|
||||
|
||||
|
||||
def detect_stream_type(url: str) -> str:
|
||||
@@ -96,14 +175,27 @@ class RecordingManager:
|
||||
self._get_ntfy().notify(event, title, message, job_ntfy_enabled=ntfy_enabled, priority=priority)
|
||||
|
||||
def _log(self, level: str, message: str, job_name: str = None):
|
||||
py_level = logging.WARNING if level == "WARN" else getattr(logging, level, logging.INFO)
|
||||
logger.log(py_level, message)
|
||||
session = get_session()
|
||||
try:
|
||||
entry = LogEntry(level=level, message=message, job_name=job_name)
|
||||
session.add(entry)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Log-Eintrag konnte nicht gespeichert werden: %s", e)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _download_path(self) -> str:
|
||||
configured = self.config.get("recording", "download_path", default=DEFAULT_DOWNLOAD_PATH)
|
||||
try:
|
||||
return validate_download_path(configured)
|
||||
except ValueError as e:
|
||||
self._log("ERROR", f"Ungültiger Download-Pfad '{configured}': {e} – verwende {DEFAULT_DOWNLOAD_PATH}")
|
||||
return DEFAULT_DOWNLOAD_PATH
|
||||
|
||||
def start_recording(
|
||||
self,
|
||||
job_id: Optional[int],
|
||||
@@ -120,13 +212,16 @@ class RecordingManager:
|
||||
plik_enabled: bool = False,
|
||||
delete_after_upload: bool = False,
|
||||
) -> dict:
|
||||
if stream_type == "auto":
|
||||
stream_url = validate_stream_url(stream_url)
|
||||
validate_ffmpeg_args(extra_ffmpeg_args or "")
|
||||
|
||||
if not stream_type or stream_type == "auto":
|
||||
stream_type = detect_stream_type(stream_url)
|
||||
|
||||
ext = output_format if output_format and output_format.lower() != "auto" else default_extension(stream_type)
|
||||
sname = safe_name(name)
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
download_path = self.config.get("recording", "download_path", default="/app/data/recordings")
|
||||
download_path = self._download_path()
|
||||
output_dir = os.path.join(download_path, sname)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
@@ -139,7 +234,7 @@ class RecordingManager:
|
||||
|
||||
scheduled_stop = None
|
||||
if max_duration and max_duration > 0:
|
||||
scheduled_stop = datetime.datetime.utcnow() + datetime.timedelta(seconds=max_duration)
|
||||
scheduled_stop = utcnow() + datetime.timedelta(seconds=max_duration)
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
@@ -206,12 +301,12 @@ class RecordingManager:
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
cmd = self._build_ffmpeg_cmd(
|
||||
stream_url, stream_type, output_file,
|
||||
extra_ffmpeg_args, segment_mode, segment_duration,
|
||||
)
|
||||
|
||||
log_fh = None
|
||||
try:
|
||||
cmd = self._build_ffmpeg_cmd(
|
||||
stream_url, stream_type, output_file,
|
||||
extra_ffmpeg_args, segment_mode, segment_duration,
|
||||
)
|
||||
log_path = os.path.join(output_dir, f".ffmpeg_{rec_id}.log")
|
||||
log_fh = open(log_path, "a")
|
||||
process = subprocess.Popen(
|
||||
@@ -221,6 +316,8 @@ class RecordingManager:
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception as e:
|
||||
if log_fh:
|
||||
log_fh.close()
|
||||
self._update_recording(rec_id, status="error", error_message=str(e),
|
||||
file_size=self._finalize_file_size(rec_id, segment_mode))
|
||||
self._log("ERROR", f"ffmpeg konnte nicht gestartet werden: {e}", job_name)
|
||||
@@ -229,6 +326,7 @@ class RecordingManager:
|
||||
|
||||
self._processes[rec_id] = process
|
||||
self._update_recording(rec_id, status="running", pid=process.pid)
|
||||
proc_started = time.monotonic()
|
||||
|
||||
process.wait()
|
||||
retcode = process.returncode
|
||||
@@ -253,6 +351,8 @@ class RecordingManager:
|
||||
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
|
||||
return
|
||||
|
||||
if time.monotonic() - proc_started >= MIN_STABLE_RUNTIME:
|
||||
retry = 0
|
||||
retry += 1
|
||||
if max_retries > 0 and retry >= max_retries:
|
||||
self._update_recording(rec_id, status="error",
|
||||
@@ -290,7 +390,11 @@ class RecordingManager:
|
||||
self._cleanup(rec_id)
|
||||
|
||||
def _build_ffmpeg_cmd(self, url, stream_type, output_file, extra_args, segment_mode, segment_duration):
|
||||
cmd = ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "warning"]
|
||||
url = validate_stream_url(url)
|
||||
extra_tokens = validate_ffmpeg_args(extra_args or "")
|
||||
|
||||
cmd = ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "warning",
|
||||
"-protocol_whitelist", PROTOCOL_WHITELIST]
|
||||
|
||||
if stream_type == "hls":
|
||||
cmd += ["-i", url, "-c", "copy", "-bsf:a", "aac_adtstoasc"]
|
||||
@@ -300,8 +404,7 @@ class RecordingManager:
|
||||
if segment_mode and segment_duration:
|
||||
cmd += ["-f", "segment", "-segment_time", str(segment_duration), "-reset_timestamps", "1"]
|
||||
|
||||
if extra_args:
|
||||
cmd += extra_args.split()
|
||||
cmd += extra_tokens
|
||||
|
||||
cmd.append(output_file)
|
||||
return cmd
|
||||
@@ -328,23 +431,25 @@ class RecordingManager:
|
||||
self._cleanup(rec_id)
|
||||
return True
|
||||
|
||||
def extend_recording(self, rec_id: int, extra_seconds: int) -> bool:
|
||||
def extend_recording(self, rec_id: int, extra_seconds: int) -> str:
|
||||
"""Ergebnis: "ok", "not_found", "not_running" oder "unlimited"."""
|
||||
session = get_session()
|
||||
try:
|
||||
rec = session.query(Recording).filter_by(id=rec_id).first()
|
||||
if not rec or rec.status != "running":
|
||||
return False
|
||||
|
||||
if rec.scheduled_stop:
|
||||
rec.scheduled_stop = rec.scheduled_stop + datetime.timedelta(seconds=extra_seconds)
|
||||
else:
|
||||
rec.scheduled_stop = datetime.datetime.utcnow() + datetime.timedelta(seconds=extra_seconds)
|
||||
if not rec:
|
||||
return "not_found"
|
||||
if rec.status != "running":
|
||||
return "not_running"
|
||||
if not rec.scheduled_stop:
|
||||
return "unlimited"
|
||||
|
||||
rec.scheduled_stop = rec.scheduled_stop + datetime.timedelta(seconds=extra_seconds)
|
||||
job_name = rec.job_name
|
||||
session.commit()
|
||||
self._log("INFO", f"Aufnahme verlängert um {format_elapsed(extra_seconds)}: {rec.job_name}", rec.job_name)
|
||||
return True
|
||||
finally:
|
||||
session.close()
|
||||
self._log("INFO", f"Aufnahme verlängert um {format_elapsed(extra_seconds)}: {job_name}", job_name)
|
||||
return "ok"
|
||||
|
||||
def remove_duration_limit(self, rec_id: int) -> bool:
|
||||
session = get_session()
|
||||
@@ -388,7 +493,7 @@ class RecordingManager:
|
||||
def check_scheduled_stops(self):
|
||||
session = get_session()
|
||||
try:
|
||||
now = datetime.datetime.utcnow()
|
||||
now = utcnow()
|
||||
recs = session.query(Recording).filter(
|
||||
Recording.status == "running",
|
||||
Recording.scheduled_stop.isnot(None),
|
||||
@@ -403,25 +508,30 @@ class RecordingManager:
|
||||
self.stop_recording(rec_id)
|
||||
|
||||
def cleanup_stale(self):
|
||||
# Nach einem Neustart kann kein ffmpeg-Kindprozess mehr laufen
|
||||
session = get_session()
|
||||
try:
|
||||
recs = session.query(Recording).filter(Recording.status.in_(["running", "starting"])).all()
|
||||
now = utcnow()
|
||||
for rec in recs:
|
||||
if rec.pid:
|
||||
try:
|
||||
os.kill(rec.pid, 0)
|
||||
except (ProcessLookupError, OSError):
|
||||
rec.status = "error"
|
||||
rec.error_message = "Process vanished"
|
||||
rec.stopped_at = datetime.datetime.utcnow()
|
||||
rec.status = "error"
|
||||
rec.error_message = STALE_ERROR_MESSAGE
|
||||
rec.stopped_at = now
|
||||
try:
|
||||
files = [rec.output_file] if rec.output_file else []
|
||||
rec.file_size, _ = self._calc_recording_size(files, bool(rec.segment_mode))
|
||||
except Exception:
|
||||
pass
|
||||
session.commit()
|
||||
if recs:
|
||||
logger.warning("%d verwaiste Aufnahme(n) als abgebrochen markiert", len(recs))
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _recording_to_dict(self, rec: Recording) -> dict:
|
||||
elapsed = 0
|
||||
if rec.started_at:
|
||||
end = rec.stopped_at or datetime.datetime.utcnow()
|
||||
end = rec.stopped_at or utcnow()
|
||||
elapsed = int((end - rec.started_at).total_seconds())
|
||||
|
||||
total_size = 0
|
||||
@@ -434,7 +544,7 @@ class RecordingManager:
|
||||
|
||||
remaining = None
|
||||
if rec.scheduled_stop and rec.status == "running":
|
||||
diff = (rec.scheduled_stop - datetime.datetime.utcnow()).total_seconds()
|
||||
diff = (rec.scheduled_stop - utcnow()).total_seconds()
|
||||
remaining = max(0, int(diff))
|
||||
|
||||
return {
|
||||
@@ -447,9 +557,9 @@ class RecordingManager:
|
||||
"output_file": rec.output_file,
|
||||
"pid": rec.pid,
|
||||
"status": rec.status,
|
||||
"started_at": rec.started_at.isoformat() if rec.started_at else None,
|
||||
"stopped_at": rec.stopped_at.isoformat() if rec.stopped_at else None,
|
||||
"scheduled_stop": rec.scheduled_stop.isoformat() if rec.scheduled_stop else None,
|
||||
"started_at": iso_utc(rec.started_at),
|
||||
"stopped_at": iso_utc(rec.stopped_at),
|
||||
"scheduled_stop": iso_utc(rec.scheduled_stop),
|
||||
"segment_mode": rec.segment_mode,
|
||||
"is_scheduled": rec.is_scheduled,
|
||||
"error_message": rec.error_message,
|
||||
@@ -463,16 +573,31 @@ class RecordingManager:
|
||||
"plik_url": rec.plik_url,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _segment_files(pattern: str) -> list[str]:
|
||||
glob_pattern = globmod.escape(pattern).replace("%03d", "*")
|
||||
return sorted(
|
||||
f for f in globmod.glob(glob_pattern)
|
||||
if os.path.isfile(f) and not os.path.basename(f).startswith(".")
|
||||
)
|
||||
|
||||
def _resolve_output_files(self, patterns: list[str], segment_mode: bool) -> list[str]:
|
||||
files = []
|
||||
for of in patterns:
|
||||
if segment_mode:
|
||||
files.extend(self._segment_files(of))
|
||||
elif os.path.isfile(of) and os.path.getsize(of) > 0:
|
||||
files.append(of)
|
||||
return [f for f in files if not os.path.basename(f).startswith(".")]
|
||||
|
||||
def _calc_recording_size(self, output_files: list[str], segment_mode: bool) -> tuple[int, int]:
|
||||
total_size = 0
|
||||
segment_count = 0
|
||||
for of in output_files:
|
||||
if segment_mode:
|
||||
pattern = of.replace('%03d', '*')
|
||||
for f in globmod.glob(pattern):
|
||||
if os.path.isfile(f):
|
||||
total_size += os.path.getsize(f)
|
||||
segment_count += 1
|
||||
for f in self._segment_files(of):
|
||||
total_size += os.path.getsize(f)
|
||||
segment_count += 1
|
||||
else:
|
||||
if os.path.isfile(of):
|
||||
total_size += os.path.getsize(of)
|
||||
@@ -494,7 +619,7 @@ class RecordingManager:
|
||||
for k, v in kwargs.items():
|
||||
setattr(rec, k, v)
|
||||
if kwargs.get("status") in ("stopped", "completed", "error"):
|
||||
rec.stopped_at = datetime.datetime.utcnow()
|
||||
rec.stopped_at = utcnow()
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
@@ -503,9 +628,10 @@ class RecordingManager:
|
||||
job_name: str, segment_mode: bool):
|
||||
opts = self._rec_options.get(rec_id, {})
|
||||
if opts.get("plik_enabled"):
|
||||
patterns = list(self._output_files.get(rec_id, [output_file]))
|
||||
files = self._resolve_output_files(patterns, segment_mode)
|
||||
self._get_plik().upload_recording(
|
||||
output_dir=output_dir,
|
||||
output_file=output_file,
|
||||
files=files,
|
||||
job_name=job_name,
|
||||
segment_mode=segment_mode,
|
||||
delete_after=opts.get("delete_after_upload", False),
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest==9.1.1
|
||||
@@ -1,8 +1,7 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.2
|
||||
sqlalchemy==2.0.40
|
||||
pydantic==2.11.3
|
||||
apscheduler==3.11.0
|
||||
pyyaml==6.0.2
|
||||
python-multipart==0.0.20
|
||||
fastapi==0.143.0
|
||||
uvicorn[standard]==0.54.0
|
||||
sqlalchemy==2.0.54
|
||||
pydantic==2.14.0
|
||||
apscheduler==3.11.3
|
||||
pyyaml==6.0.3
|
||||
httpx==0.28.1
|
||||
|
||||
+242
-96
@@ -1,7 +1,13 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from sqlalchemy import or_
|
||||
from models import Job, Recording, get_session
|
||||
from models import Job, Recording, get_session, prune_logs, utcnow
|
||||
|
||||
logger = logging.getLogger("scheduler")
|
||||
|
||||
DAY_MAP = {
|
||||
"mo": 1, "mon": 1, "montag": 1,
|
||||
@@ -13,6 +19,8 @@ DAY_MAP = {
|
||||
"so": 7, "sun": 7, "sonntag": 7,
|
||||
}
|
||||
|
||||
_TIME_RE = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
|
||||
|
||||
|
||||
def _day_to_num(day: str) -> int:
|
||||
return DAY_MAP.get(day.lower().strip(), 0)
|
||||
@@ -27,6 +35,8 @@ def _matches_day(today_num: int, schedule: str) -> bool:
|
||||
parts = entry.split("-", 1)
|
||||
start = _day_to_num(parts[0])
|
||||
end = _day_to_num(parts[1])
|
||||
if not start or not end:
|
||||
continue
|
||||
if start <= end:
|
||||
if start <= today_num <= end:
|
||||
return True
|
||||
@@ -39,7 +49,69 @@ def _matches_day(today_num: int, schedule: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_valid_time(t) -> bool:
|
||||
return isinstance(t, str) and bool(_TIME_RE.match(t))
|
||||
|
||||
|
||||
def is_valid_date(d) -> bool:
|
||||
if not isinstance(d, str) or not re.match(r"^\d{4}-\d{2}-\d{2}$", d):
|
||||
return False
|
||||
try:
|
||||
datetime.date.fromisoformat(d)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def is_valid_days(days) -> bool:
|
||||
if not isinstance(days, str):
|
||||
return False
|
||||
days = days.strip()
|
||||
if days == "*":
|
||||
return True
|
||||
if not days:
|
||||
return False
|
||||
for entry in days.split(","):
|
||||
entry = entry.strip()
|
||||
parts = entry.split("-", 1) if "-" in entry else [entry]
|
||||
if not all(_day_to_num(p) for p in parts):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_schedule(job: dict):
|
||||
"""Prüft Zeitplanfelder. Wirft ValueError mit deutscher Meldung."""
|
||||
start = job.get("schedule_start") or ""
|
||||
stop = job.get("schedule_stop") or ""
|
||||
date = job.get("schedule_date") or ""
|
||||
end_date = job.get("schedule_end_date") or ""
|
||||
days = job.get("schedule_days")
|
||||
days = "*" if days is None else days
|
||||
|
||||
if start and not is_valid_time(start):
|
||||
raise ValueError("Startzeit muss im Format HH:MM angegeben werden")
|
||||
if stop and not is_valid_time(stop):
|
||||
raise ValueError("Stoppzeit muss im Format HH:MM angegeben werden")
|
||||
if date and not is_valid_date(date):
|
||||
raise ValueError("Datum muss im Format JJJJ-MM-TT angegeben werden")
|
||||
if end_date and not is_valid_date(end_date):
|
||||
raise ValueError("Enddatum muss im Format JJJJ-MM-TT angegeben werden")
|
||||
if not is_valid_days(days):
|
||||
raise ValueError("Ungültige Wochentage (erlaubt: * oder z.B. mo-fr,sa)")
|
||||
if job.get("schedule_enabled") and not start:
|
||||
raise ValueError("Für einen aktiven Zeitplan ist eine Startzeit erforderlich")
|
||||
if job.get("schedule_once") and not date:
|
||||
raise ValueError("Für eine einmalige Aufnahme ist ein Datum erforderlich")
|
||||
if end_date:
|
||||
if not date:
|
||||
raise ValueError("Enddatum erfordert ein Startdatum")
|
||||
if end_date < date:
|
||||
raise ValueError("Enddatum darf nicht vor dem Startdatum liegen")
|
||||
|
||||
|
||||
def _time_to_minutes(t: str) -> int:
|
||||
if not is_valid_time(t):
|
||||
raise ValueError(f"Ungültige Uhrzeit: {t!r}")
|
||||
parts = t.split(":")
|
||||
return int(parts[0]) * 60 + int(parts[1])
|
||||
|
||||
@@ -58,40 +130,78 @@ def _apply_buffers(job: dict) -> dict:
|
||||
eff = dict(job)
|
||||
pre = job.get("pre_buffer", 0) or 0
|
||||
post = job.get("post_buffer", 0) or 0
|
||||
if pre > 0 and eff.get("schedule_start"):
|
||||
if pre > 0 and is_valid_time(eff.get("schedule_start")):
|
||||
m = _time_to_minutes(eff["schedule_start"]) - pre
|
||||
if m < 0:
|
||||
m += 1440
|
||||
m %= 1440
|
||||
eff["schedule_start"] = f"{m // 60:02d}:{m % 60:02d}"
|
||||
if post > 0 and eff.get("schedule_stop"):
|
||||
if post > 0 and is_valid_time(eff.get("schedule_stop")):
|
||||
m = _time_to_minutes(eff["schedule_stop"]) + post
|
||||
if m >= 1440:
|
||||
m -= 1440
|
||||
m %= 1440
|
||||
eff["schedule_stop"] = f"{m // 60:02d}:{m % 60:02d}"
|
||||
return eff
|
||||
|
||||
|
||||
def _is_multi_day(job: dict) -> bool:
|
||||
return bool(job.get("schedule_once") and job.get("schedule_date") and job.get("schedule_end_date"))
|
||||
|
||||
|
||||
def compute_duration(job: dict, apply_buffers: bool) -> int | None:
|
||||
"""Aufnahmedauer in Sekunden aus max_duration bzw. Zeitplan. Wirft ValueError bei ungültigen Daten."""
|
||||
duration = job.get("max_duration")
|
||||
if duration:
|
||||
return int(duration)
|
||||
start = job.get("schedule_start") or ""
|
||||
stop = job.get("schedule_stop") or ""
|
||||
if not start or not stop:
|
||||
return None
|
||||
|
||||
pre = (job.get("pre_buffer") or 0) if apply_buffers else 0
|
||||
post = (job.get("post_buffer") or 0) if apply_buffers else 0
|
||||
|
||||
if _is_multi_day(job):
|
||||
try:
|
||||
start_dt = datetime.datetime.strptime(f"{job['schedule_date']} {start}", "%Y-%m-%d %H:%M")
|
||||
end_dt = datetime.datetime.strptime(f"{job['schedule_end_date']} {stop}", "%Y-%m-%d %H:%M")
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("Ungültiges Datum oder Uhrzeit im Zeitplan")
|
||||
start_dt -= datetime.timedelta(minutes=pre)
|
||||
end_dt += datetime.timedelta(minutes=post)
|
||||
return max(0, int((end_dt - start_dt).total_seconds()))
|
||||
|
||||
eff = _apply_buffers({**job, "pre_buffer": pre, "post_buffer": post})
|
||||
start_min = _time_to_minutes(eff["schedule_start"])
|
||||
stop_min = _time_to_minutes(eff["schedule_stop"])
|
||||
if stop_min > start_min:
|
||||
return (stop_min - start_min) * 60
|
||||
return (1440 - start_min + stop_min) * 60
|
||||
|
||||
|
||||
class StreamScheduler:
|
||||
def __init__(self, recorder):
|
||||
self.recorder = recorder
|
||||
self._scheduler = BackgroundScheduler()
|
||||
self._started_keys: set[str] = set()
|
||||
self._last_errors: dict[int, str] = {}
|
||||
|
||||
def start(self):
|
||||
self._scheduler.add_job(self._tick, "interval", seconds=30, id="scheduler_tick",
|
||||
replace_existing=True, max_instances=1)
|
||||
self._scheduler.add_job(self.recorder.check_scheduled_stops, "interval", seconds=10,
|
||||
id="check_stops", replace_existing=True, max_instances=1)
|
||||
self._scheduler.add_job(self.prune_logs, "interval", hours=24,
|
||||
id="prune_logs", replace_existing=True, max_instances=1)
|
||||
self._scheduler.start()
|
||||
|
||||
def stop(self):
|
||||
self._scheduler.shutdown(wait=False)
|
||||
|
||||
def _occurrence_key(self, job: Job) -> str:
|
||||
today = datetime.date.today().isoformat()
|
||||
if job.schedule_once:
|
||||
today = job.schedule_date or today
|
||||
return f"{job.id}|{job.schedule_once}|{today}|{job.schedule_start}"
|
||||
@staticmethod
|
||||
def prune_logs():
|
||||
try:
|
||||
days = int(os.environ.get("LOG_RETENTION_DAYS", "90"))
|
||||
except ValueError:
|
||||
days = 90
|
||||
return prune_logs(days)
|
||||
|
||||
def _original_occ_key(self, job: dict, now: datetime.datetime | None = None) -> str:
|
||||
now = now or datetime.datetime.now()
|
||||
@@ -103,7 +213,7 @@ class StreamScheduler:
|
||||
now = now or datetime.datetime.now()
|
||||
today_num = now.isoweekday()
|
||||
today_date = now.strftime("%Y-%m-%d")
|
||||
now_time = now.strftime("%H:%M")
|
||||
now_min = now.hour * 60 + now.minute
|
||||
|
||||
occ_key = (
|
||||
f"{job['id']}|{job['schedule_once']}|"
|
||||
@@ -111,43 +221,58 @@ class StreamScheduler:
|
||||
f"{job['schedule_start']}"
|
||||
)
|
||||
|
||||
is_today = False
|
||||
multi_day = bool(job.get("schedule_end_date") and job["schedule_once"])
|
||||
multi_day = _is_multi_day(job)
|
||||
state = {
|
||||
"occ_key": occ_key,
|
||||
"in_window": False,
|
||||
"multi_day": multi_day,
|
||||
"is_cross_midnight": False,
|
||||
}
|
||||
|
||||
start = job.get("schedule_start") or ""
|
||||
stop = job.get("schedule_stop") or ""
|
||||
if not is_valid_time(start):
|
||||
return state
|
||||
if not is_valid_time(stop):
|
||||
stop = ""
|
||||
days = job.get("schedule_days") or "*"
|
||||
|
||||
is_today = False
|
||||
if job["schedule_once"]:
|
||||
if job["schedule_date"] == today_date:
|
||||
is_today = True
|
||||
else:
|
||||
if _matches_day(today_num, job["schedule_days"] or "*"):
|
||||
if _matches_day(today_num, days):
|
||||
is_today = True
|
||||
|
||||
start_min = _time_to_minutes(start)
|
||||
in_window = False
|
||||
is_cross_midnight = False
|
||||
if is_today:
|
||||
if multi_day:
|
||||
now_min = _time_to_minutes(now_time)
|
||||
start_min = _time_to_minutes(job["schedule_start"])
|
||||
if multi_day or not stop:
|
||||
in_window = now_min >= start_min
|
||||
elif job["schedule_stop"]:
|
||||
start_min = _time_to_minutes(job["schedule_start"])
|
||||
stop_min = _time_to_minutes(job["schedule_stop"])
|
||||
else:
|
||||
stop_min = _time_to_minutes(stop)
|
||||
if stop_min <= start_min:
|
||||
is_cross_midnight = True
|
||||
now_min = _time_to_minutes(now_time)
|
||||
in_window = now_min >= start_min
|
||||
else:
|
||||
in_window = _in_time_window(now_time, job["schedule_start"], job["schedule_stop"])
|
||||
else:
|
||||
now_min = _time_to_minutes(now_time)
|
||||
start_min = _time_to_minutes(job["schedule_start"])
|
||||
in_window = now_min >= start_min
|
||||
in_window = start_min <= now_min < stop_min
|
||||
elif stop:
|
||||
stop_min = _time_to_minutes(stop)
|
||||
if stop_min <= start_min and stop_min > 0 and now_min < stop_min:
|
||||
yesterday = now - datetime.timedelta(days=1)
|
||||
if job["schedule_once"]:
|
||||
was_yesterday = job["schedule_date"] == yesterday.strftime("%Y-%m-%d")
|
||||
else:
|
||||
was_yesterday = _matches_day(yesterday.isoweekday(), days)
|
||||
if was_yesterday:
|
||||
is_cross_midnight = True
|
||||
in_window = True
|
||||
|
||||
return {
|
||||
"occ_key": occ_key,
|
||||
"in_window": in_window,
|
||||
"multi_day": multi_day,
|
||||
"is_cross_midnight": is_cross_midnight,
|
||||
}
|
||||
state["in_window"] = in_window
|
||||
state["is_cross_midnight"] = is_cross_midnight
|
||||
return state
|
||||
|
||||
def suppress_current_occurrence(self, job_id: int) -> bool:
|
||||
session = get_session()
|
||||
@@ -169,9 +294,12 @@ class StreamScheduler:
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
occ_key = self._original_occ_key(job)
|
||||
eff_job = _apply_buffers(job)
|
||||
state = self._schedule_state(eff_job)
|
||||
try:
|
||||
occ_key = self._original_occ_key(job)
|
||||
state = self._schedule_state(_apply_buffers(job))
|
||||
except Exception as e:
|
||||
logger.warning("Zeitplan von Job %s ungültig: %s", job_id, e)
|
||||
return False
|
||||
if not state["in_window"]:
|
||||
return False
|
||||
|
||||
@@ -187,9 +315,7 @@ class StreamScheduler:
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
local_utc_offset = datetime.datetime.now() - datetime.datetime.utcnow()
|
||||
return local_start - local_utc_offset
|
||||
return local_start.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
||||
|
||||
def _has_recording_for_occurrence(self, job: dict, now: datetime.datetime) -> bool:
|
||||
occurrence_start = self._occurrence_start_utc(job, now)
|
||||
@@ -218,7 +344,7 @@ class StreamScheduler:
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _get_active_scheduled_recording_for_job(self, job_id: int):
|
||||
def _get_active_scheduled_recording_for_job(self, job_id: int, *, respect_stop: bool = False):
|
||||
session = get_session()
|
||||
try:
|
||||
rec = session.query(Recording).filter(
|
||||
@@ -226,12 +352,25 @@ class StreamScheduler:
|
||||
Recording.status.in_(["running", "starting"]),
|
||||
Recording.is_scheduled == True,
|
||||
).first()
|
||||
return rec.id if rec else None
|
||||
if not rec:
|
||||
return None
|
||||
if respect_stop and rec.scheduled_stop and rec.scheduled_stop > utcnow():
|
||||
return None
|
||||
return rec.id
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _prune_started_keys(self, now: datetime.datetime):
|
||||
cutoff = (now - datetime.timedelta(days=2)).strftime("%Y-%m-%d")
|
||||
for key in list(self._started_keys):
|
||||
parts = key.split("|")
|
||||
date = parts[2] if len(parts) >= 4 else ""
|
||||
if not is_valid_date(date) or date < cutoff:
|
||||
self._started_keys.discard(key)
|
||||
|
||||
def _tick(self):
|
||||
now = datetime.datetime.now()
|
||||
self._prune_started_keys(now)
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
@@ -255,61 +394,68 @@ class StreamScheduler:
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
active_ids = set()
|
||||
for job in job_list:
|
||||
if not job["schedule_start"]:
|
||||
continue
|
||||
active_ids.add(job["id"])
|
||||
try:
|
||||
self._process_job(job, now)
|
||||
self._last_errors.pop(job["id"], None)
|
||||
except Exception as e:
|
||||
err = f"{type(e).__name__}: {e}"
|
||||
if self._last_errors.get(job["id"]) != err:
|
||||
self._last_errors[job["id"]] = err
|
||||
logger.error("Zeitplan-Fehler bei Job %s (%s): %s", job["id"], job.get("name"), err)
|
||||
try:
|
||||
self.recorder._log("ERROR", f"Zeitplan-Fehler: {e}", job.get("name"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
occ_key = self._original_occ_key(job, now)
|
||||
eff_job = _apply_buffers(job)
|
||||
state = self._schedule_state(eff_job, now)
|
||||
in_window = state["in_window"]
|
||||
for job_id in list(self._last_errors):
|
||||
if job_id not in active_ids:
|
||||
self._last_errors.pop(job_id, None)
|
||||
|
||||
if in_window:
|
||||
if (
|
||||
occ_key not in self._started_keys
|
||||
and not self._is_job_recording(job["id"])
|
||||
and not self._has_recording_for_occurrence(job, now)
|
||||
):
|
||||
duration = job["max_duration"]
|
||||
if not duration and eff_job["schedule_stop"]:
|
||||
if state["multi_day"]:
|
||||
start_dt = datetime.datetime.strptime(
|
||||
f"{job['schedule_date']} {eff_job['schedule_start']}", "%Y-%m-%d %H:%M")
|
||||
end_dt = datetime.datetime.strptime(
|
||||
f"{job['schedule_end_date']} {eff_job['schedule_stop']}", "%Y-%m-%d %H:%M")
|
||||
duration = max(0, int((end_dt - start_dt).total_seconds()))
|
||||
else:
|
||||
start_min = _time_to_minutes(eff_job["schedule_start"])
|
||||
stop_min = _time_to_minutes(eff_job["schedule_stop"])
|
||||
if stop_min > start_min:
|
||||
duration = (stop_min - start_min) * 60
|
||||
else:
|
||||
duration = (1440 - start_min + stop_min) * 60
|
||||
def _process_job(self, job: dict, now: datetime.datetime):
|
||||
if not job["schedule_start"]:
|
||||
return
|
||||
|
||||
self.recorder.start_recording(
|
||||
job_id=job["id"],
|
||||
name=job["name"],
|
||||
stream_url=job["stream_url"],
|
||||
stream_type=job["stream_type"],
|
||||
output_format=job["output_format"] or "",
|
||||
max_duration=duration,
|
||||
extra_ffmpeg_args=job["extra_ffmpeg_args"] or "",
|
||||
segment_duration=job["segment_duration"],
|
||||
is_scheduled=True,
|
||||
description=job.get("description", ""),
|
||||
ntfy_enabled=job.get("ntfy_enabled", False),
|
||||
plik_enabled=job.get("plik_enabled", False),
|
||||
delete_after_upload=job.get("delete_after_upload", False),
|
||||
)
|
||||
self._started_keys.add(occ_key)
|
||||
else:
|
||||
if (
|
||||
not state["multi_day"]
|
||||
and not state["is_cross_midnight"]
|
||||
and eff_job["schedule_stop"]
|
||||
and self._is_job_recording(job["id"])
|
||||
):
|
||||
rec_id = self._get_active_scheduled_recording_for_job(job["id"])
|
||||
if rec_id:
|
||||
self.recorder.stop_recording(rec_id)
|
||||
self._started_keys.discard(occ_key)
|
||||
occ_key = self._original_occ_key(job, now)
|
||||
eff_job = _apply_buffers(job)
|
||||
state = self._schedule_state(eff_job, now)
|
||||
|
||||
if state["in_window"]:
|
||||
if (
|
||||
occ_key not in self._started_keys
|
||||
and not self._is_job_recording(job["id"])
|
||||
and not self._has_recording_for_occurrence(job, now)
|
||||
):
|
||||
duration = compute_duration(job, apply_buffers=True)
|
||||
# Erst merken, damit ein Startfehler nicht alle 30s erneut versucht wird
|
||||
self._started_keys.add(occ_key)
|
||||
self.recorder.start_recording(
|
||||
job_id=job["id"],
|
||||
name=job["name"],
|
||||
stream_url=job["stream_url"],
|
||||
stream_type=job["stream_type"],
|
||||
output_format=job["output_format"] or "",
|
||||
max_duration=duration,
|
||||
extra_ffmpeg_args=job["extra_ffmpeg_args"] or "",
|
||||
segment_duration=job["segment_duration"],
|
||||
is_scheduled=True,
|
||||
description=job.get("description", ""),
|
||||
ntfy_enabled=job.get("ntfy_enabled", False),
|
||||
plik_enabled=job.get("plik_enabled", False),
|
||||
delete_after_upload=job.get("delete_after_upload", False),
|
||||
)
|
||||
else:
|
||||
if (
|
||||
not state["multi_day"]
|
||||
and not state["is_cross_midnight"]
|
||||
and eff_job["schedule_stop"]
|
||||
and self._is_job_recording(job["id"])
|
||||
):
|
||||
rec_id = self._get_active_scheduled_recording_for_job(
|
||||
job["id"], respect_stop=True,
|
||||
)
|
||||
if rec_id:
|
||||
self.recorder.stop_recording(rec_id)
|
||||
self._started_keys.discard(occ_key)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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
@@ -8,6 +8,7 @@
|
||||
|
||||
recording:
|
||||
# Basis-Verzeichnis für Aufnahmen
|
||||
# Muss innerhalb von RECORDING_ROOTS liegen (Standard: /app/data/recordings:/recordings)
|
||||
download_path: /app/data/recordings
|
||||
|
||||
# Wiederholungsversuche bei Verbindungsabbruch (0 = unbegrenzt)
|
||||
@@ -16,17 +17,21 @@ recording:
|
||||
# Wartezeit zwischen Wiederholungsversuchen (Sekunden)
|
||||
retry_delay: 5
|
||||
|
||||
# Wird nur aus dieser Datei gelesen (nicht über Web-UI/API änderbar)
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
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:
|
||||
enabled: false
|
||||
enabled: true
|
||||
username: admin
|
||||
password: stream-recorder
|
||||
|
||||
# NTFY Push-Benachrichtigungen (optional)
|
||||
# Token und API-Key werden von der API nie zurückgegeben.
|
||||
ntfy:
|
||||
url: ""
|
||||
token: ""
|
||||
@@ -43,5 +48,6 @@ schedule:
|
||||
default_pre_buffer: 5
|
||||
default_post_buffer: 5
|
||||
|
||||
# Log-Level (DEBUG, INFO, WARN, ERROR)
|
||||
logging:
|
||||
level: INFO
|
||||
|
||||
@@ -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
@@ -256,7 +256,7 @@
|
||||
.log-time { color: var(--text-dim); white-space: nowrap; }
|
||||
.log-level { font-weight: 600; min-width: 42px; }
|
||||
.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.DEBUG { color: var(--text-dim); }
|
||||
.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>
|
||||
<button class="burger" id="burgerBtn" onclick="toggleBurger()" aria-label="Menü">☰</button>
|
||||
<div class="tabs" id="navTabs">
|
||||
<button class="tab active" onclick="switchTab('dashboard')">Dashboard</button>
|
||||
<button class="tab" onclick="switchTab('jobs')">Jobs</button>
|
||||
<button class="tab" onclick="switchTab('stations')">Sender</button>
|
||||
<button class="tab" onclick="switchTab('logs')">Logs</button>
|
||||
<button class="tab" onclick="switchTab('settings')">Einstellungen</button>
|
||||
<button class="tab active" data-tab="dashboard" onclick="switchTab('dashboard')">Dashboard</button>
|
||||
<button class="tab" data-tab="jobs" onclick="switchTab('jobs')">Jobs</button>
|
||||
<button class="tab" data-tab="stations" onclick="switchTab('stations')">Sender</button>
|
||||
<button class="tab" data-tab="logs" onclick="switchTab('logs')">Logs</button>
|
||||
<button class="tab" data-tab="settings" onclick="switchTab('settings')">Einstellungen</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -727,15 +727,16 @@
|
||||
<div class="modal" style="max-width:400px">
|
||||
<div class="modal-title">Aufnahme verlängern</div>
|
||||
<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>
|
||||
<div class="extend-options">
|
||||
<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>
|
||||
<p id="extend-unlimited-info" style="display:none;color:var(--text-dim);font-size:0.85rem;margin-bottom:12px">∞ 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(30)">+30 Min</button>
|
||||
<button class="btn" onclick="doExtend(60)">+1 Std</button>
|
||||
<button class="btn" onclick="doExtend(120)">+2 Std</button>
|
||||
<button class="btn btn-success" onclick="doUnlimited()">Unbegrenzt</button>
|
||||
</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>
|
||||
<div style="display:flex;gap:8px;margin-top:4px">
|
||||
<input type="number" id="extend-custom" placeholder="z.B. 45" style="flex:1">
|
||||
@@ -766,16 +767,46 @@
|
||||
const API = '';
|
||||
let stationsCache = [];
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(API + path, {
|
||||
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||||
...opts,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || res.statusText);
|
||||
let authLost = false;
|
||||
|
||||
function formatErrorDetail(detail) {
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map(item => {
|
||||
if (item && typeof item === 'object') {
|
||||
const loc = Array.isArray(item.loc) ? item.loc.slice(1).join('.') : '';
|
||||
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
|
||||
@@ -786,20 +817,11 @@ function toggleBurger() {
|
||||
// Tabs
|
||||
function switchTab(name) {
|
||||
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.querySelector(`#panel-${name}`).classList.add('active');
|
||||
const tabLabel = {
|
||||
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');
|
||||
});
|
||||
panel.classList.add('active');
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||||
if (name === 'logs') loadLogs();
|
||||
if (name === 'jobs') loadJobs();
|
||||
if (name === 'stations') loadStations();
|
||||
@@ -807,18 +829,35 @@ function switchTab(name) {
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const active = await api('/api/recordings?active_only=true');
|
||||
const recent = await api('/api/recordings?limit=20');
|
||||
let dashInFlight = false;
|
||||
let dashPending = false;
|
||||
|
||||
renderActive(active);
|
||||
renderRecent(recent.filter(r => r.status !== 'running' && r.status !== 'starting'));
|
||||
async function loadDashboard() {
|
||||
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) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
dashInFlight = false;
|
||||
if (dashPending) {
|
||||
dashPending = false;
|
||||
loadDashboard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pollDashboard() {
|
||||
if (document.hidden || authLost) return;
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
function renderActive(recs) {
|
||||
const el = document.getElementById('activeRecordings');
|
||||
if (!recs.length) {
|
||||
@@ -826,8 +865,11 @@ function renderActive(recs) {
|
||||
return;
|
||||
}
|
||||
el.innerHTML = recs.map(r => {
|
||||
const remainClass = r.remaining !== null
|
||||
? (r.remaining < 300 ? 'critical' : r.remaining < 900 ? 'low' : '')
|
||||
const id = intId(r.id);
|
||||
const hasLimit = r.remaining !== null && r.remaining !== undefined;
|
||||
const remaining = Number(r.remaining);
|
||||
const remainClass = hasLimit
|
||||
? (remaining < 300 ? 'critical' : remaining < 900 ? 'low' : '')
|
||||
: '';
|
||||
return `
|
||||
<div class="card">
|
||||
@@ -835,19 +877,19 @@ function renderActive(recs) {
|
||||
<div class="card-title">
|
||||
<span class="rec-dot"></span>
|
||||
${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>' : ''}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-sm btn-success" onclick="openExtend(${r.id})">Verlängern</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="stopRec(${r.id})">Stoppen</button>
|
||||
</div>
|
||||
${id !== null ? `<div class="actions">
|
||||
<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(${id})">Stoppen</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="meta-item">🕓 ${r.elapsed_display}</span>
|
||||
<span class="meta-item">💾 ${r.total_size_display}</span>
|
||||
${r.segment_count !== null ? `<span class="meta-item">📄 ${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">∞ Unbegrenzt</span>'}
|
||||
<span class="meta-item">🕓 ${esc(r.elapsed_display)}</span>
|
||||
<span class="meta-item">💾 ${esc(r.total_size_display)}</span>
|
||||
${r.segment_count != null ? `<span class="meta-item">📄 ${esc(r.segment_count)} Segmente</span>` : ''}
|
||||
${hasLimit ? `<span class="meta-item">Verbleibend: <span class="remaining ${remainClass}">${esc(r.remaining_display)}</span></span>` : '<span class="meta-item">∞ Unbegrenzt</span>'}
|
||||
</div>
|
||||
<div class="meta" style="margin-top:4px">
|
||||
<span class="meta-item" style="opacity:0.6">${esc(r.stream_url)}</span>
|
||||
@@ -863,19 +905,20 @@ function renderRecent(recs) {
|
||||
return;
|
||||
}
|
||||
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 `
|
||||
<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>
|
||||
<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">📤 Plik</span>' : ''}
|
||||
</div>
|
||||
<div class="recent-meta">
|
||||
<span class="rm-dur">🕓 ${r.elapsed_display}</span>
|
||||
<span class="rm-size">💾 ${r.total_size_display}</span>
|
||||
<span class="rm-date">${started}</span>
|
||||
<span class="rm-dur">🕓 ${esc(r.elapsed_display)}</span>
|
||||
<span class="rm-size">💾 ${esc(r.total_size_display)}</span>
|
||||
<span class="rm-date">${esc(started)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -884,22 +927,28 @@ function renderRecent(recs) {
|
||||
|
||||
async function openRecDetail(recId) {
|
||||
try {
|
||||
const r = await api('/api/recordings/' + recId);
|
||||
const started = r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : '—';
|
||||
const stopped = r.stopped_at ? new Date(r.stopped_at).toLocaleString('de-DE') : '—';
|
||||
const fileName = r.output_file ? r.output_file.split('/').pop() : null;
|
||||
const id = intId(recId);
|
||||
if (id === null) return;
|
||||
const r = await api('/api/recordings/' + id);
|
||||
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 =
|
||||
`<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">📤 Plik</span>' : '');
|
||||
|
||||
let html = '';
|
||||
|
||||
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">🔗 Im Browser öffnen</a>`
|
||||
: esc(plikUrl);
|
||||
html += `<div class="rec-detail-item full">
|
||||
<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">🔗 Im Browser öffnen</a></span>
|
||||
<span class="rec-detail-value">${plikValue}</span>
|
||||
</div>
|
||||
<hr class="rec-detail-divider">`;
|
||||
}
|
||||
@@ -907,19 +956,19 @@ async function openRecDetail(recId) {
|
||||
html += `
|
||||
<div class="rec-detail-item">
|
||||
<span class="rec-detail-label">Gestartet</span>
|
||||
<span class="rec-detail-value">${started}</span>
|
||||
<span class="rec-detail-value">${esc(started)}</span>
|
||||
</div>
|
||||
<div class="rec-detail-item">
|
||||
<span class="rec-detail-label">Beendet</span>
|
||||
<span class="rec-detail-value">${stopped}</span>
|
||||
<span class="rec-detail-value">${esc(stopped)}</span>
|
||||
</div>
|
||||
<div class="rec-detail-item">
|
||||
<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 class="rec-detail-item">
|
||||
<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>`;
|
||||
|
||||
if (r.error_message) {
|
||||
@@ -952,7 +1001,7 @@ async function openRecDetail(recId) {
|
||||
document.getElementById('recDetailBody').innerHTML = html;
|
||||
openModal('recDetailModal');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Fehler: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -971,9 +1020,11 @@ function populateStationSelects() {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const selected = el.value;
|
||||
el.innerHTML = '<option value="">URL manuell eingeben</option>' + stationsCache.map(s =>
|
||||
`<option value="${s.id}">${esc(s.name)}${s.description ? ' - ' + esc(s.description) : ''}</option>`
|
||||
).join('');
|
||||
el.innerHTML = '<option value="">URL manuell eingeben</option>' + stationsCache.map(s => {
|
||||
const id = intId(s.id);
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -986,31 +1037,34 @@ async function loadStations() {
|
||||
el.innerHTML = '<div class="empty"><div class="empty-icon">📻</div>Keine Sender gespeichert<br><button class="btn btn-primary" style="margin-top:12px" onclick="openStationModal()">Ersten Sender anlegen</button></div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = stationsCache.map(s => `
|
||||
el.innerHTML = stationsCache.map(s => {
|
||||
const id = intId(s.id);
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
${esc(s.name)}
|
||||
<span class="badge badge-scheduled">${esc(s.stream_type || 'auto')}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-sm" onclick="openStationModal(${s.id})">Bearbeiten</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteStation(${s.id})">Löschen</button>
|
||||
</div>
|
||||
${id !== null ? `<div class="actions">
|
||||
<button class="btn btn-sm" onclick="openStationModal(${id})">Bearbeiten</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteStation(${id})">Löschen</button>
|
||||
</div>` : ''}
|
||||
</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">
|
||||
<span class="meta-item">${esc(s.stream_url)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
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('stationModalTitle').textContent = station ? 'Sender bearbeiten' : 'Neuer Sender';
|
||||
document.getElementById('station-name').value = station ? station.name : '';
|
||||
@@ -1048,8 +1102,12 @@ async function saveStation() {
|
||||
|
||||
async function deleteStation(id) {
|
||||
if (!confirm('Sender wirklich löschen?')) return;
|
||||
await api(`/api/stations/${id}`, { method: 'DELETE' });
|
||||
loadStations();
|
||||
try {
|
||||
await api(`/api/stations/${intId(id)}`, { method: 'DELETE' });
|
||||
loadStations();
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStationToQuickRecord() {
|
||||
@@ -1087,7 +1145,9 @@ async function loadJobs() {
|
||||
el.innerHTML = '<div class="empty"><div class="empty-icon">📋</div>Keine Jobs konfiguriert<br><button class="btn btn-primary" style="margin-top:12px" onclick="openJobModal()">Ersten Job erstellen</button></div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = jobs.map(j => `
|
||||
el.innerHTML = jobs.map(j => {
|
||||
const id = intId(j.id);
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
@@ -1095,24 +1155,25 @@ async function loadJobs() {
|
||||
${j.is_recording ? '<span class="badge badge-running">läuft</span>' : ''}
|
||||
${j.schedule_enabled ? '<span class="badge badge-scheduled">geplant</span>' : ''}
|
||||
</div>
|
||||
<div class="actions">
|
||||
${!j.is_recording ? `<button class="btn btn-sm btn-success" onclick="startJob(${j.id})">Aufnehmen</button>` : ''}
|
||||
<button class="btn btn-sm" onclick="editJob(${j.id})">Bearbeiten</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteJob(${j.id})">Löschen</button>
|
||||
</div>
|
||||
${id !== null ? `<div class="actions">
|
||||
${!j.is_recording ? `<button class="btn btn-sm btn-success" onclick="startJob(${id})">Aufnehmen</button>` : ''}
|
||||
<button class="btn btn-sm" onclick="editJob(${id})">Bearbeiten</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteJob(${id})">Löschen</button>
|
||||
</div>` : ''}
|
||||
</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">
|
||||
<span class="meta-item">${esc(j.stream_type || 'auto')}</span>
|
||||
<span class="meta-item">${j.station_name ? '📻 ' + esc(j.station_name) : esc(j.stream_url)}</span>
|
||||
${j.max_duration ? `<span class="meta-item">🕓 ${Math.round(j.max_duration/60)} Min</span>` : ''}
|
||||
${j.segment_duration ? `<span class="meta-item">📄 alle ${Math.round(j.segment_duration/60)} Min</span>` : ''}
|
||||
${j.schedule_enabled ? `<span class="meta-item">📅 ${j.schedule_once ? j.schedule_date : j.schedule_days} ${j.schedule_start}${j.schedule_stop ? ' - '+j.schedule_stop : ''}${j.pre_buffer ? ' (−'+j.pre_buffer+'min)' : ''}${j.post_buffer ? ' (+'+j.post_buffer+'min)' : ''}</span>` : ''}
|
||||
${j.max_duration ? `<span class="meta-item">🕓 ${esc(Math.round(j.max_duration/60))} Min</span>` : ''}
|
||||
${j.segment_duration ? `<span class="meta-item">📄 alle ${esc(Math.round(j.segment_duration/60))} Min</span>` : ''}
|
||||
${j.schedule_enabled ? `<span class="meta-item">📅 ${esc(j.schedule_once ? j.schedule_date : j.schedule_days)} ${esc(j.schedule_start)}${j.schedule_stop ? ' - ' + esc(j.schedule_stop) : ''}${j.pre_buffer ? ' (−' + esc(j.pre_buffer) + 'min)' : ''}${j.post_buffer ? ' (+' + esc(j.post_buffer) + 'min)' : ''}</span>` : ''}
|
||||
${j.ntfy_enabled ? `<span class="meta-item">🔔 NTFY</span>` : ''}
|
||||
${j.plik_enabled ? `<span class="meta-item">📤 Plik${j.delete_after_upload ? ' (danach löschen)' : ''}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
@@ -1155,10 +1216,16 @@ function openJobModal(job = null) {
|
||||
}
|
||||
|
||||
async function editJob(id) {
|
||||
const job = await api(`/api/jobs/${id}`);
|
||||
openJobModal(job);
|
||||
try {
|
||||
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() {
|
||||
const editId = document.getElementById('job-edit-id').value;
|
||||
const durMin = document.getElementById('job-duration').value;
|
||||
@@ -1191,10 +1258,40 @@ async function saveJob() {
|
||||
alert('Name und URL sind Pflichtfelder');
|
||||
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 {
|
||||
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 {
|
||||
await api('/api/jobs', { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
@@ -1207,13 +1304,17 @@ async function saveJob() {
|
||||
|
||||
async function deleteJob(id) {
|
||||
if (!confirm('Job wirklich löschen?')) return;
|
||||
await api(`/api/jobs/${id}`, { method: 'DELETE' });
|
||||
loadJobs();
|
||||
try {
|
||||
await api(`/api/jobs/${intId(id)}`, { method: 'DELETE' });
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function startJob(id) {
|
||||
try {
|
||||
await api(`/api/jobs/${id}/record`, { method: 'POST' });
|
||||
await api(`/api/jobs/${intId(id)}/record`, { method: 'POST' });
|
||||
loadDashboard();
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
@@ -1323,6 +1424,10 @@ async function startQuickRecord() {
|
||||
const url = document.getElementById('qr-url').value;
|
||||
if (!url) { alert('URL ist ein Pflichtfeld'); return; }
|
||||
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 {
|
||||
await api('/api/quick-record', {
|
||||
method: 'POST',
|
||||
@@ -1344,54 +1449,97 @@ async function startQuickRecord() {
|
||||
// Recording actions
|
||||
async function stopRec(id) {
|
||||
if (!confirm('Aufnahme wirklich stoppen?')) return;
|
||||
await api(`/api/recordings/${id}/stop`, { method: 'POST' });
|
||||
loadDashboard();
|
||||
try {
|
||||
await api(`/api/recordings/${intId(id)}/stop`, { method: 'POST' });
|
||||
loadDashboard();
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openExtend(id) {
|
||||
document.getElementById('extend-rec-id').value = id;
|
||||
function openExtend(id, hasLimit = true) {
|
||||
document.getElementById('extend-rec-id').value = intId(id) ?? '';
|
||||
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');
|
||||
}
|
||||
|
||||
async function doExtend(minutes) {
|
||||
const id = document.getElementById('extend-rec-id').value;
|
||||
await api(`/api/recordings/${id}/extend?minutes=${minutes}`, { method: 'POST' });
|
||||
closeModal('extendModal');
|
||||
loadDashboard();
|
||||
const id = intId(document.getElementById('extend-rec-id').value);
|
||||
const min = Number(minutes);
|
||||
if (id === null) return;
|
||||
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() {
|
||||
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));
|
||||
}
|
||||
|
||||
async function doUnlimited() {
|
||||
const id = document.getElementById('extend-rec-id').value;
|
||||
await api(`/api/recordings/${id}/unlimited`, { method: 'POST' });
|
||||
closeModal('extendModal');
|
||||
loadDashboard();
|
||||
const id = intId(document.getElementById('extend-rec-id').value);
|
||||
if (id === null) return;
|
||||
try {
|
||||
await api(`/api/recordings/${id}/unlimited`, { method: 'POST' });
|
||||
closeModal('extendModal');
|
||||
loadDashboard();
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Logs
|
||||
const LOG_LEVELS = new Set(['DEBUG', 'INFO', 'WARN', 'WARNING', 'ERROR']);
|
||||
|
||||
async function loadLogs() {
|
||||
const level = document.getElementById('logLevel').value;
|
||||
const logs = await api(`/api/logs?limit=200${level ? '&level='+level : ''}`);
|
||||
const el = document.getElementById('logContainer');
|
||||
if (!logs.length) {
|
||||
el.innerHTML = '<span style="color:var(--text-dim)">Keine Logeinträge</span>';
|
||||
return;
|
||||
try {
|
||||
const level = document.getElementById('logLevel').value;
|
||||
const logs = await api(`/api/logs?limit=200${level ? '&level=' + encodeURIComponent(level) : ''}`) || [];
|
||||
const el = document.getElementById('logContainer');
|
||||
if (!logs.length) {
|
||||
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
|
||||
let settingsCfg = null;
|
||||
|
||||
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');
|
||||
el.innerHTML = `
|
||||
<div class="settings-section">
|
||||
@@ -1403,11 +1551,11 @@ async function loadSettings() {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<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>
|
||||
@@ -1426,7 +1574,7 @@ async function loadSettings() {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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>
|
||||
@@ -1435,11 +1583,11 @@ async function loadSettings() {
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<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>
|
||||
@@ -1452,7 +1600,11 @@ async function loadSettings() {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<label>Events (kommasepariert)</label>
|
||||
@@ -1469,7 +1621,11 @@ async function loadSettings() {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<label>TTL</label>
|
||||
@@ -1484,42 +1640,68 @@ async function loadSettings() {
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const prev = settingsCfg || {};
|
||||
const passVal = document.getElementById('cfg-auth-pass').value;
|
||||
const authData = {
|
||||
enabled: document.getElementById('cfg-auth-enabled').checked,
|
||||
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;
|
||||
window._defaultPostBuffer = parseInt(document.getElementById('cfg-post-buffer').value) || 5;
|
||||
const ntfyData = {
|
||||
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 = {
|
||||
recording: {
|
||||
download_path: document.getElementById('cfg-download-path').value,
|
||||
max_retries: parseInt(document.getElementById('cfg-max-retries').value) || 5,
|
||||
retry_delay: parseInt(document.getElementById('cfg-retry-delay').value) || 5,
|
||||
max_retries: intOr(document.getElementById('cfg-max-retries').value, 5),
|
||||
retry_delay: intOr(document.getElementById('cfg-retry-delay').value, 5),
|
||||
},
|
||||
schedule: {
|
||||
default_pre_buffer: parseInt(document.getElementById('cfg-pre-buffer').value) || 5,
|
||||
default_post_buffer: parseInt(document.getElementById('cfg-post-buffer').value) || 5,
|
||||
default_pre_buffer: intOr(document.getElementById('cfg-pre-buffer').value, 5),
|
||||
default_post_buffer: intOr(document.getElementById('cfg-post-buffer').value, 5),
|
||||
},
|
||||
auth: authData,
|
||||
ntfy: {
|
||||
url: document.getElementById('cfg-ntfy-url').value,
|
||||
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,
|
||||
},
|
||||
ntfy: ntfyData,
|
||||
plik: plikData,
|
||||
};
|
||||
|
||||
try {
|
||||
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) {
|
||||
alert('Fehler: ' + e.message);
|
||||
}
|
||||
@@ -1528,7 +1710,28 @@ async function saveSettings() {
|
||||
// Helpers
|
||||
function openModal(id) { document.getElementById(id).classList.add('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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
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
|
||||
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
|
||||
document.addEventListener('click', e => {
|
||||
const tabs = document.getElementById('navTabs');
|
||||
@@ -1559,7 +1769,10 @@ async function loadDefaults() {
|
||||
loadStationOptions();
|
||||
loadDefaults();
|
||||
loadDashboard();
|
||||
setInterval(loadDashboard, 5000);
|
||||
setInterval(pollDashboard, 5000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && !authLost) loadDashboard();
|
||||
});
|
||||
|
||||
api('/api/health').then(h => {
|
||||
document.getElementById('footerVersion').textContent = 'Stream-Recorder v' + h.version;
|
||||
|
||||
+26
-2
@@ -4,16 +4,40 @@ services:
|
||||
container_name: stream-recorder
|
||||
restart: unless-stopped
|
||||
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:
|
||||
- ./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
|
||||
networks:
|
||||
stream-recorder_net:
|
||||
ipv4_address: 172.16.84.10
|
||||
environment:
|
||||
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:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8484/api/health"]
|
||||
interval: 30s
|
||||
|
||||
+56
-8
@@ -2,6 +2,8 @@
|
||||
|
||||
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
|
||||
|
||||
### System
|
||||
@@ -9,8 +11,8 @@ Alle Funktionen des Web-UI sind auch per REST-API verfügbar. Basis-URL: `http:/
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|-------------|
|
||||
| `GET` | `/api/health` | Health Check (kein Auth nötig) |
|
||||
| `GET` | `/api/config` | Konfiguration lesen |
|
||||
| `PUT` | `/api/config` | Konfiguration speichern |
|
||||
| `GET` | `/api/config` | Konfiguration lesen (ohne Secrets) |
|
||||
| `PUT` | `/api/config` | Konfiguration speichern (siehe [Konfiguration](#konfiguration)) |
|
||||
|
||||
### 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 |
|
||||
| `GET` | `/api/jobs/:id` | Job-Details abrufen |
|
||||
| `PUT` | `/api/jobs/:id` | Job aktualisieren |
|
||||
| `DELETE` | `/api/jobs/:id` | Job löschen |
|
||||
| `POST` | `/api/jobs/:id/record` | Job-Aufnahme starten |
|
||||
| `DELETE` | `/api/jobs/:id` | Job löschen (nicht während einer laufenden Aufnahme → 400) |
|
||||
| `POST` | `/api/jobs/:id/record` | Job-Aufnahme starten (läuft bereits → 409) |
|
||||
|
||||
### 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 |
|
||||
| `GET` | `/api/stations/:id` | Sender-Details abrufen |
|
||||
| `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
|
||||
|
||||
@@ -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
|
||||
|
||||
| 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/:id` | Aufnahme-Status |
|
||||
| `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 |
|
||||
|
||||
### 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`) |
|
||||
|
||||
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
|
||||
|
||||
Bei aktiviertem Auth wird Basic Authentication verwendet:
|
||||
Bei aktiviertem Auth (Standard bei Neuinstallationen) wird Basic Authentication verwendet:
|
||||
|
||||
```bash
|
||||
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
@@ -27,9 +27,11 @@ Unterhalb der aktiven Aufnahmen zeigt das Dashboard die letzten abgeschlossenen
|
||||
|
||||
Laufende Aufnahmen können verlängert werden:
|
||||
- **+15 / +30 / +60 / +120 Minuten** — Schnellauswahl
|
||||
- **Eigene Dauer** — Beliebige Minutenzahl eingeben
|
||||
- **Eigene Dauer** — Minutenzahl zwischen 1 und 1440 eingeben
|
||||
- **Unbegrenzt** — Zeitlimit komplett entfernen
|
||||
|
||||
Eine bereits unbegrenzte Aufnahme kann nicht weiter verlängert werden.
|
||||
|
||||
## Aufnahme stoppen
|
||||
|
||||
Ü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.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
|
||||
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
@@ -13,7 +13,7 @@ Im Web-UI unter **Jobs → + Neuer Job**.
|
||||
| Feld | Beschreibung |
|
||||
|------|-------------|
|
||||
| **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
|
||||
|
||||
@@ -25,7 +25,39 @@ Im Web-UI unter **Jobs → + Neuer Job**.
|
||||
| **Ausgabeformat** | Dateiendung. Leer = automatisch (mp4 für Video, mp3 für Audio) |
|
||||
| **Max. Dauer** | Zeitlimit in Minuten. Entfällt bei Zeitplan mit Endzeit |
|
||||
| **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
|
||||
|
||||
@@ -85,7 +117,7 @@ Die globale NTFY- und Plik-Konfiguration (URL, Token, etc.) erfolgt in den [Eins
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+46
-6
@@ -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`.
|
||||
|
||||
Die Datei enthält Secrets und wird beim Containerstart auf die Rechte `600` gesetzt.
|
||||
|
||||
## Abschnitte
|
||||
|
||||
### Aufnahme
|
||||
|
||||
| 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.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.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
|
||||
|
||||
| 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.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
|
||||
|
||||
@@ -47,7 +62,7 @@ Standardwerte für die Vor- und Nachlaufzeit bei neuen Jobs mit Zeitplan.
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|-----------|----------|-------------|
|
||||
| `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` |
|
||||
|
||||
### Plik
|
||||
@@ -55,7 +70,7 @@ Standardwerte für die Vor- und Nachlaufzeit bei neuen Jobs mit Zeitplan.
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|-----------|----------|-------------|
|
||||
| `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.) |
|
||||
|
||||
### 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` |
|
||||
|
||||
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
|
||||
|
||||
```yaml
|
||||
@@ -79,7 +119,7 @@ server:
|
||||
auth:
|
||||
enabled: true
|
||||
username: admin
|
||||
password: mein-passwort
|
||||
password: mein-passwort # wird beim Start in password_hash umgewandelt
|
||||
|
||||
schedule:
|
||||
default_pre_buffer: 5
|
||||
|
||||
+4
-2
@@ -23,7 +23,7 @@ Push-Nachrichten per [ntfy.sh](https://ntfy.sh) bei verschiedenen Events.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user