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