Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6caad3c3c4 | ||
|
|
aec64719f3 | ||
|
|
66edfbf8d9 | ||
|
|
65b0b5ac0f | ||
|
|
c3f68ace96 | ||
|
|
6d07b12015 | ||
|
|
50bb72803f | ||
|
|
a9a9a89cba | ||
|
|
ce1ccb91bb | ||
|
|
851ff086ac | ||
|
|
1ad87c7186 | ||
|
|
b446ce9b3e | ||
|
|
9fa589789e | ||
|
|
794156a088 |
@@ -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
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: Build Dev Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
paths-ignore:
|
||||||
|
- 'docs/**'
|
||||||
|
- 'README.md'
|
||||||
|
- 'LICENSE'
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.techniverse.net
|
||||||
|
IMAGE: git.techniverse.net/scriptos/stream-recorder
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-amd64:
|
||||||
|
runs-on: linux-amd64
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
run: echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${GITHUB_ACTOR}" --password-stdin
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push amd64 image
|
||||||
|
run: |
|
||||||
|
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||||
|
|
||||||
|
docker build --pull --platform linux/amd64 \
|
||||||
|
--build-arg APP_VERSION="dev-${SHORT_SHA}" \
|
||||||
|
-t "${IMAGE}:dev-amd64" \
|
||||||
|
-t "${IMAGE}:dev-${GITHUB_SHA}-amd64" \
|
||||||
|
.
|
||||||
|
docker push "${IMAGE}:dev-amd64"
|
||||||
|
docker push "${IMAGE}:dev-${GITHUB_SHA}-amd64"
|
||||||
|
|
||||||
|
build-arm64:
|
||||||
|
runs-on: linux-arm64
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
run: echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${GITHUB_ACTOR}" --password-stdin
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push arm64 image
|
||||||
|
run: |
|
||||||
|
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||||
|
|
||||||
|
docker build --pull --platform linux/arm64 \
|
||||||
|
--build-arg APP_VERSION="dev-${SHORT_SHA}" \
|
||||||
|
-t "${IMAGE}:dev-arm64" \
|
||||||
|
-t "${IMAGE}:dev-${GITHUB_SHA}-arm64" \
|
||||||
|
.
|
||||||
|
docker push "${IMAGE}:dev-arm64"
|
||||||
|
docker push "${IMAGE}:dev-${GITHUB_SHA}-arm64"
|
||||||
|
|
||||||
|
publish-dev-manifest:
|
||||||
|
runs-on: linux-amd64
|
||||||
|
needs: [build-amd64, build-arm64]
|
||||||
|
steps:
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
run: echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${GITHUB_ACTOR}" --password-stdin
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Publish dev manifests
|
||||||
|
run: |
|
||||||
|
docker manifest create "${IMAGE}:dev-latest" \
|
||||||
|
--amend "${IMAGE}:dev-amd64" \
|
||||||
|
--amend "${IMAGE}:dev-arm64"
|
||||||
|
docker manifest push "${IMAGE}:dev-latest"
|
||||||
|
|
||||||
|
docker manifest create "${IMAGE}:dev-${GITHUB_SHA}" \
|
||||||
|
--amend "${IMAGE}:dev-${GITHUB_SHA}-amd64" \
|
||||||
|
--amend "${IMAGE}:dev-${GITHUB_SHA}-arm64"
|
||||||
|
docker manifest push "${IMAGE}:dev-${GITHUB_SHA}"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: Build Release Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.techniverse.net
|
||||||
|
IMAGE: git.techniverse.net/scriptos/stream-recorder
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-amd64:
|
||||||
|
runs-on: linux-amd64
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
run: echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${GITHUB_ACTOR}" --password-stdin
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push amd64 release image
|
||||||
|
run: |
|
||||||
|
RELEASE_TAG="${GITHUB_REF_NAME}"
|
||||||
|
VERSION="${RELEASE_TAG#v}"
|
||||||
|
|
||||||
|
docker build --pull --platform linux/amd64 \
|
||||||
|
--build-arg APP_VERSION="${VERSION}" \
|
||||||
|
-t "${IMAGE}:${RELEASE_TAG}-amd64" \
|
||||||
|
.
|
||||||
|
docker push "${IMAGE}:${RELEASE_TAG}-amd64"
|
||||||
|
|
||||||
|
build-arm64:
|
||||||
|
runs-on: linux-arm64
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
run: echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${GITHUB_ACTOR}" --password-stdin
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push arm64 release image
|
||||||
|
run: |
|
||||||
|
RELEASE_TAG="${GITHUB_REF_NAME}"
|
||||||
|
VERSION="${RELEASE_TAG#v}"
|
||||||
|
|
||||||
|
docker build --pull --platform linux/arm64 \
|
||||||
|
--build-arg APP_VERSION="${VERSION}" \
|
||||||
|
-t "${IMAGE}:${RELEASE_TAG}-arm64" \
|
||||||
|
.
|
||||||
|
docker push "${IMAGE}:${RELEASE_TAG}-arm64"
|
||||||
|
|
||||||
|
publish-release-manifest:
|
||||||
|
runs-on: linux-amd64
|
||||||
|
needs: [build-amd64, build-arm64]
|
||||||
|
steps:
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
run: echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${GITHUB_ACTOR}" --password-stdin
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Publish release manifests
|
||||||
|
run: |
|
||||||
|
RELEASE_TAG="${GITHUB_REF_NAME}"
|
||||||
|
|
||||||
|
docker manifest create "${IMAGE}:${RELEASE_TAG}" \
|
||||||
|
--amend "${IMAGE}:${RELEASE_TAG}-amd64" \
|
||||||
|
--amend "${IMAGE}:${RELEASE_TAG}-arm64"
|
||||||
|
docker manifest push "${IMAGE}:${RELEASE_TAG}"
|
||||||
|
|
||||||
|
docker manifest create "${IMAGE}:latest" \
|
||||||
|
--amend "${IMAGE}:${RELEASE_TAG}-amd64" \
|
||||||
|
--amend "${IMAGE}:${RELEASE_TAG}-arm64"
|
||||||
|
docker manifest push "${IMAGE}:latest"
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# Benutzerspezifische Konfiguration (aus .dist erstellt)
|
# Persistente Daten (DB, Config, Recordings)
|
||||||
config/stream-recorder.conf
|
data/
|
||||||
|
|
||||||
# Job-Dateien (nur Vorlagen werden versioniert)
|
# Python
|
||||||
jobs/*.job
|
__pycache__/
|
||||||
!jobs/templates/
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends ffmpeg curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Unprivilegierter Laufzeit-User (UID/GID per PUID/PGID zur Laufzeit anpassbar)
|
||||||
|
RUN groupadd -g 1000 app && \
|
||||||
|
useradd -u 1000 -g app -M -d /nonexistent -s /usr/sbin/nologin app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY bin/backend/requirements.txt .
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Code bleibt root-gehörig und für den app-User schreibgeschützt
|
||||||
|
COPY bin/backend/ /app/
|
||||||
|
COPY bin/frontend/ /app/frontend/
|
||||||
|
COPY bin/config.yml.dist /app/config.yml.dist
|
||||||
|
COPY bin/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \
|
||||||
|
chmod 755 /usr/local/bin/docker-entrypoint.sh && \
|
||||||
|
mkdir -p /app/data/recordings && \
|
||||||
|
chown -R app:app /app/data
|
||||||
|
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
|
ENV DATA_DIR=/app/data
|
||||||
|
ENV STATIC_DIR=/app/frontend/static
|
||||||
|
ENV PUID=1000 \
|
||||||
|
PGID=1000 \
|
||||||
|
RECORDING_ROOTS=/app/data/recordings:/recordings
|
||||||
|
|
||||||
|
EXPOSE 8484
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8484/api/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
CMD ["python", "app.py"]
|
||||||
@@ -1,9 +1,30 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2023 scriptos
|
---
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
Copyright © Patrick Asmus (scriptos)
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
Website: https://www.patrick-asmus.de
|
||||||
|
Blog: https://www.cleveradmin.de
|
||||||
|
Email: support@techniverse.net
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<h1 align="center">stream-recorder</h1>
|
<h1 align="center">stream-recorder</h1>
|
||||||
|
|
||||||
<h4 align="center">
|
<h4 align="center">
|
||||||
Generischer Stream-Recorder für RTMP, HLS, HTTP-Audio und weitere Formate
|
Generischer Stream-Recorder für RTMP, HLS, HTTP-Audio und weitere Formate — mit Web-UI und Docker
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<h6 align="center">
|
<h6 align="center">
|
||||||
@@ -21,90 +21,141 @@
|
|||||||
</h6>
|
</h6>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
|
|
||||||
|
- **Web-Interface** — Jobs planen, Aufnahmen steuern und verlängern im Browser
|
||||||
|
- **Docker-basiert** — Ein Container, läuft auf ARM und x86/x64
|
||||||
- **Mehrere Stream-Typen** — RTMP, RTMPS, HLS (m3u8), MP3, AAC, OGG, FLAC und HTTP-Streams
|
- **Mehrere Stream-Typen** — RTMP, RTMPS, HLS (m3u8), MP3, AAC, OGG, FLAC und HTTP-Streams
|
||||||
- **Job-basiert** — Ein Stream = eine Job-Datei, inklusive Zeitplanung
|
- **Aufnahmen verlängern** — Laufende Aufnahmen spontan verlängern oder auf unbegrenzt setzen
|
||||||
- **Segment-Splitting** — Lange Aufnahmen automatisch in Teile zerlegen (z.B. stündlich)
|
- **Zeitplanung** — Automatische Aufnahmezeiten direkt im Web-UI konfigurieren
|
||||||
- **Plik-Upload** — Aufnahmen und Segmente automatisch auf Plik hochladen
|
- **Segment-Splitting** — Lange Aufnahmen automatisch in Teile zerlegen
|
||||||
- **Zeitplanung** — Aufnahmezeiten direkt in der Job-Datei konfigurieren (kein Cron nötig)
|
|
||||||
- **Auto-Reconnect** — Automatische Wiederverbindung bei Verbindungsabbrüchen
|
- **Auto-Reconnect** — Automatische Wiederverbindung bei Verbindungsabbrüchen
|
||||||
- **Live-Status** — Laufende Aufnahmen mit Laufzeit, Dateigröße und Segment-Anzahl anzeigen
|
- **Schnellaufnahme** — URL eingeben, sofort aufnehmen
|
||||||
- **NTFY-Benachrichtigungen** — Push-Nachrichten bei Start, Stop, Fehler und Upload
|
- **NTFY-Benachrichtigungen** — Push-Nachrichten bei Start, Stop, Fehler und Upload (pro Job schaltbar)
|
||||||
- **Schnellaufnahme** — Interaktiver Modus für spontane Aufnahmen
|
- **Plik-Upload** — Aufnahmen automatisch hochladen, optional danach löschen (pro Job schaltbar)
|
||||||
|
- **Metadata-Monitor** — Automatische Show-Erkennung via Stream-Metadaten (Plugin)
|
||||||
## Voraussetzungen
|
- **REST-API** — Alle Funktionen auch per API steuerbar
|
||||||
|
|
||||||
- **Linux** (getestet unter Ubuntu/Debian)
|
|
||||||
- **Bash** 4.0+
|
|
||||||
- **ffmpeg** (`sudo apt install ffmpeg`)
|
|
||||||
- **curl** (optional, für NTFY und Plik)
|
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Klonen und einrichten
|
# Repository klonen
|
||||||
git clone https://git.techniverse.net/scriptos/stream-recorder.git /opt/stream-recorder
|
git clone https://git.techniverse.net/scriptos/stream-recorder.git
|
||||||
cd /opt/stream-recorder
|
cd stream-recorder
|
||||||
cp config/stream-recorder.conf.dist config/stream-recorder.conf
|
|
||||||
nano config/stream-recorder.conf
|
|
||||||
|
|
||||||
# Scheduler installieren (optional)
|
# Container starten
|
||||||
sudo install/install.sh
|
docker compose up -d
|
||||||
|
|
||||||
# Ersten Job erstellen
|
|
||||||
./stream-recorder.sh create
|
|
||||||
|
|
||||||
# Oder direkt loslegen
|
|
||||||
./stream-recorder.sh quick
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Befehle
|
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
|
```bash
|
||||||
./stream-recorder.sh record [job] # Aufnahme starten (ohne Angabe: Auswahl)
|
docker logs stream-recorder
|
||||||
./stream-recorder.sh stop [job] # Aufnahme stoppen (ohne Angabe: Auswahl)
|
```
|
||||||
./stream-recorder.sh status # Laufende Aufnahmen anzeigen
|
|
||||||
./stream-recorder.sh list # Alle Jobs anzeigen
|
Das Passwort kann danach unter **Einstellungen** geändert werden.
|
||||||
./stream-recorder.sh create # Neuen Job erstellen
|
|
||||||
./stream-recorder.sh quick # Schnellaufnahme
|
## Container Image
|
||||||
./stream-recorder.sh logs # Logs live verfolgen
|
|
||||||
|
Release-Images werden automatisch in der Gitea Container Registry veröffentlicht:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull git.techniverse.net/scriptos/stream-recorder:latest
|
||||||
|
docker pull git.techniverse.net/scriptos/stream-recorder:v3.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Dev-Images entstehen bei Pushes auf `main` oder `dev`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull git.techniverse.net/scriptos/stream-recorder:dev-latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
stream-recorder:
|
||||||
|
image: git.techniverse.net/scriptos/stream-recorder:latest
|
||||||
|
container_name: stream-recorder
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8484:8484"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- CHOWN
|
||||||
|
- DAC_OVERRIDE
|
||||||
|
- FOWNER
|
||||||
|
- SETUID
|
||||||
|
- SETGID
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Port ist standardmäßig nur auf `127.0.0.1` gebunden. Für Zugriff von außen einen Reverse-Proxy (mit TLS) davorschalten oder gezielt an eine IP binden, z.B. eine Tailscale-IP (`"100.64.0.10:8484:8484"`).
|
||||||
|
|
||||||
|
### Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Standard | Beschreibung |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `TZ` | — | Zeitzone (z.B. `Europe/Berlin`) |
|
||||||
|
| `PUID` / `PGID` | `1000` | UID/GID, unter der die Anwendung im Container läuft |
|
||||||
|
| `RECORDING_ROOTS` | `/app/data/recordings:/recordings` | Erlaubte Basisverzeichnisse für `download_path` (durch `:` getrennt) |
|
||||||
|
| `LOG_RETENTION_DAYS` | `90` | Log-Einträge älter als X Tage werden gelöscht |
|
||||||
|
| `ENABLE_API_DOCS` | — | `1` aktiviert die Swagger-UI unter `/docs` |
|
||||||
|
|
||||||
|
## Daten
|
||||||
|
|
||||||
|
Alle persistenten Daten liegen in einem einzigen Volume (`./data`):
|
||||||
|
|
||||||
|
| Pfad | Beschreibung |
|
||||||
|
|------|-------------|
|
||||||
|
| `data/config.yml` | Konfiguration (wird beim ersten Start automatisch erstellt, Rechte `600`) |
|
||||||
|
| `data/stream-recorder.db` | SQLite-Datenbank |
|
||||||
|
| `data/recordings/` | Aufnahmen (pro Job ein Unterordner) |
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- **Anmeldung** — Basic Auth ist bei Neuinstallationen aktiv. Passwörter werden als PBKDF2-Hash gespeichert. Nach 10 fehlgeschlagenen Logins innerhalb von 5 Minuten wird die IP für den Rest des Zeitfensters gesperrt (HTTP 429).
|
||||||
|
- **Secrets** — Passwort, NTFY-Token und Plik-API-Key werden von der API nie zurückgegeben.
|
||||||
|
- **Kein Root** — Der Container startet als root, passt die Besitzrechte von `data/` an und startet die Anwendung dann als `PUID`/`PGID` (Standard `1000`). Der Anwendungscode ist für diesen User schreibgeschützt.
|
||||||
|
- **Eingaben** — Stream-URLs nur per `http(s)`, `rtmp(s)` und `rtsp(s)`; ffmpeg läuft mit Protokoll-Whitelist. Extra ffmpeg-Argumente unterliegen einer Allowlist (siehe [Jobs](docs/jobs.md#extra-ffmpeg-argumente)). `download_path` muss innerhalb von `RECORDING_ROOTS` liegen.
|
||||||
|
- **Web** — Kein CORS; schreibende Cross-Site-Requests werden abgewiesen (403). Security-Header und CSP sind gesetzt. Die Swagger-UI (`/docs`) ist standardmäßig deaktiviert.
|
||||||
|
- **Netzwerk** — Den Port nicht ungeschützt ins Internet stellen; Reverse-Proxy mit TLS oder VPN verwenden.
|
||||||
|
|
||||||
|
## Upgrade bestehender Installationen
|
||||||
|
|
||||||
|
- **Besitzrechte** — Beim Start werden `data/` und die Verzeichnisse aus `RECORDING_ROOTS` automatisch auf `PUID:PGID` umgestellt (nur Dateien mit abweichendem Besitzer). Soll ein anderer Host-User Besitzer sein, `PUID`/`PGID` setzen.
|
||||||
|
- **Anmeldung** — Die bisherige Auth-Einstellung bleibt erhalten. Ein Klartext-Passwort in `config.yml` wird beim Start automatisch in einen Hash umgewandelt.
|
||||||
|
- **Secrets rotieren** — Ältere Versionen haben NTFY-Token und Plik-API-Key über `/api/config` ausgeliefert. War die Instanz für andere erreichbar, diese Secrets erneuern.
|
||||||
|
- **Port-Bindung** — Die mitgelieferte `docker-compose.yml` bindet nur noch an `127.0.0.1`. Bei Bedarf anpassen (siehe oben).
|
||||||
|
- **download_path** — Liegt ein eigener Aufnahmepfad außerhalb von `/app/data/recordings` und `/recordings`, muss er in `RECORDING_ROOTS` aufgenommen werden.
|
||||||
|
- **Extra ffmpeg-Argumente** — Bestehende Jobs mit nicht erlaubten Argumenten müssen angepasst werden.
|
||||||
|
- **Laufende Aufnahmen** — Aufnahmen, die beim Neustart liefen, werden als Fehler „Abgebrochen durch Neustart" markiert.
|
||||||
|
|
||||||
|
## Multi-Arch Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t stream-recorder:latest .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dokumentation
|
## Dokumentation
|
||||||
|
|
||||||
| Thema | Beschreibung |
|
| Thema | Beschreibung |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| [Installation](docs/installation.md) | Installation, Updates, Deinstallation |
|
| [Jobs](docs/jobs.md) | Jobs erstellen, Zeitplanung, Schnellaufnahme |
|
||||||
| [Konfiguration](docs/konfiguration.md) | Alle Konfigurationsparameter |
|
| [Aufnahmen](docs/aufnahmen.md) | Verlängern, Stoppen, Reconnect, Segment-Splitting |
|
||||||
| [Job-Dateien](docs/jobs.md) | Jobs erstellen und verwalten |
|
| [Plugins](docs/plugins.md) | NTFY, Plik-Upload, Metadata-Monitor |
|
||||||
| [Zeitplanung](docs/zeitplanung.md) | Automatische Aufnahmezeiten |
|
| [Konfiguration](docs/konfiguration.md) | config.yml, alle Parameter |
|
||||||
| [Segment-Splitting](docs/segment-splitting.md) | Aufnahmen in Teile zerlegen |
|
| [API](docs/api.md) | REST-API Endpunkte |
|
||||||
| [Plik-Upload](docs/plik-upload.md) | Automatischer Upload auf Plik |
|
|
||||||
| [Benachrichtigungen](docs/benachrichtigungen.md) | NTFY Push-Nachrichten |
|
|
||||||
| [Befehle](docs/befehle.md) | Alle Befehle im Detail |
|
|
||||||
| [Beispiele](docs/beispiele.md) | Praxisbeispiele und Workflows |
|
|
||||||
|
|
||||||
## Verzeichnisstruktur
|
|
||||||
|
|
||||||
```
|
|
||||||
stream-recorder/
|
|
||||||
├── stream-recorder.sh # Hauptscript
|
|
||||||
├── config/
|
|
||||||
│ └── stream-recorder.conf.dist # Konfigurations-Vorlage
|
|
||||||
├── jobs/ # Job-Definitionen
|
|
||||||
│ └── templates/ # Vorlagen
|
|
||||||
│ ├── example-rtmp.job.dist
|
|
||||||
│ ├── example-hls.job.dist
|
|
||||||
│ └── example-mp3.job.dist
|
|
||||||
├── docs/ # Dokumentation
|
|
||||||
├── install/
|
|
||||||
│ ├── install.sh # Scheduler installieren
|
|
||||||
│ └── uninstall.sh # Scheduler deinstallieren
|
|
||||||
├── README.md
|
|
||||||
└── LICENSE
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
<br><br>
|
<br><br>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -0,0 +1,909 @@
|
|||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import contextlib
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Annotated, Literal, Optional
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from config import Config, hash_password, verify_password, validate_download_path
|
||||||
|
from models import (init_db, get_session, iso_utc, utcnow,
|
||||||
|
Job, Station, Recording as RecordingModel, LogEntry)
|
||||||
|
from recorder import RecordingManager, validate_ffmpeg_args, validate_stream_url
|
||||||
|
from scheduler import StreamScheduler, compute_duration, validate_schedule
|
||||||
|
|
||||||
|
APP_VERSION = os.environ.get("APP_VERSION", "dev")
|
||||||
|
|
||||||
|
DATA_DIR = os.environ.get("DATA_DIR", "/app/data")
|
||||||
|
STATIC_DIR = os.environ.get("STATIC_DIR", "/app/frontend/static")
|
||||||
|
ENABLE_API_DOCS = os.environ.get("ENABLE_API_DOCS", "") == "1"
|
||||||
|
|
||||||
|
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||||
|
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR")
|
||||||
|
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
config = Config(os.path.join(DATA_DIR, "config.yml"))
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_log_level(level: str):
|
||||||
|
level = str(level or "INFO").upper()
|
||||||
|
logging.getLogger().setLevel(getattr(logging, level, logging.INFO) if level in LOG_LEVELS else logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
logging.basicConfig(format=LOG_FORMAT, stream=sys.stdout, force=True)
|
||||||
|
_apply_log_level(config.get("logging", "level", default="INFO"))
|
||||||
|
logging.getLogger("apscheduler").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
logger = logging.getLogger("app")
|
||||||
|
|
||||||
|
if config.password_migrated:
|
||||||
|
logger.warning("Klartext-Passwort in config.yml wurde in einen Hash migriert")
|
||||||
|
|
||||||
|
if config.default_password_active:
|
||||||
|
logger.warning("Basic-Auth nutzt das bekannte Standardpasswort – bitte in den Einstellungen ändern!")
|
||||||
|
|
||||||
|
if config.generated_password:
|
||||||
|
logger.warning("=" * 60)
|
||||||
|
logger.warning("Erster Start: Basic-Auth wurde aktiviert.")
|
||||||
|
logger.warning("Benutzer: %s", config.get("auth", "username", default="admin"))
|
||||||
|
logger.warning("Passwort: %s", config.generated_password)
|
||||||
|
logger.warning("Bitte nach dem ersten Login in den Einstellungen ändern.")
|
||||||
|
logger.warning("=" * 60)
|
||||||
|
config.generated_password = None
|
||||||
|
|
||||||
|
init_db(os.path.join(DATA_DIR, "stream-recorder.db"))
|
||||||
|
|
||||||
|
recorder = RecordingManager(config)
|
||||||
|
scheduler = StreamScheduler(recorder)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def lifespan(app):
|
||||||
|
recorder.cleanup_stale()
|
||||||
|
scheduler.prune_logs()
|
||||||
|
scheduler.start()
|
||||||
|
yield
|
||||||
|
scheduler.stop()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="stream-recorder",
|
||||||
|
version=APP_VERSION,
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url="/docs" if ENABLE_API_DOCS else None,
|
||||||
|
redoc_url="/redoc" if ENABLE_API_DOCS else None,
|
||||||
|
openapi_url="/openapi.json" if ENABLE_API_DOCS else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Auth ---
|
||||||
|
|
||||||
|
AUTH_CACHE_MAX = 256
|
||||||
|
AUTH_FAIL_LIMIT = 10
|
||||||
|
AUTH_FAIL_WINDOW = 300
|
||||||
|
EXEMPT_PATHS = {"/api/health"}
|
||||||
|
|
||||||
|
_auth_lock = threading.Lock()
|
||||||
|
_auth_cache: dict[str, str] = {}
|
||||||
|
_auth_failures: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def clear_auth_cache():
|
||||||
|
with _auth_lock:
|
||||||
|
_auth_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_auth_failures():
|
||||||
|
with _auth_lock:
|
||||||
|
_auth_failures.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_failures(ip: str, now: float) -> list[float]:
|
||||||
|
fails = [t for t in _auth_failures.get(ip, []) if now - t < AUTH_FAIL_WINDOW]
|
||||||
|
if fails:
|
||||||
|
_auth_failures[ip] = fails
|
||||||
|
else:
|
||||||
|
_auth_failures.pop(ip, None)
|
||||||
|
return fails
|
||||||
|
|
||||||
|
|
||||||
|
def _register_failure(ip: str, now: float):
|
||||||
|
with _auth_lock:
|
||||||
|
if len(_auth_failures) > 10000:
|
||||||
|
for key in list(_auth_failures):
|
||||||
|
_recent_failures(key, now)
|
||||||
|
_auth_failures.setdefault(ip, []).append(now)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_credentials(auth_header: str) -> bool:
|
||||||
|
expected_user = config.get("auth", "username", default="admin")
|
||||||
|
pw_hash = config.get("auth", "password_hash", default="")
|
||||||
|
if not pw_hash:
|
||||||
|
return False
|
||||||
|
cache_key = hashlib.sha256(auth_header.encode("utf-8", "surrogateescape")).hexdigest()
|
||||||
|
marker = f"{expected_user}\x00{pw_hash}"
|
||||||
|
with _auth_lock:
|
||||||
|
if _auth_cache.get(cache_key) == marker:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not auth_header.startswith("Basic "):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(auth_header[6:], validate=True).decode("utf-8")
|
||||||
|
username, password = decoded.split(":", 1)
|
||||||
|
except (binascii.Error, UnicodeDecodeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
user_ok = hmac.compare_digest(username.encode("utf-8"), str(expected_user).encode("utf-8"))
|
||||||
|
pass_ok = verify_password(password, pw_hash)
|
||||||
|
if not (user_ok and pass_ok):
|
||||||
|
return False
|
||||||
|
|
||||||
|
with _auth_lock:
|
||||||
|
if len(_auth_cache) >= AUTH_CACHE_MAX:
|
||||||
|
_auth_cache.pop(next(iter(_auth_cache)))
|
||||||
|
_auth_cache[cache_key] = marker
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _unauthorized():
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "Unauthorized"},
|
||||||
|
headers={"WWW-Authenticate": 'Basic realm="stream-recorder"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def auth_middleware(request: Request, call_next):
|
||||||
|
if not config.get("auth", "enabled", default=False):
|
||||||
|
return await call_next(request)
|
||||||
|
if request.url.path in EXEMPT_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
now = time.monotonic()
|
||||||
|
with _auth_lock:
|
||||||
|
blocked = len(_recent_failures(ip, now)) >= AUTH_FAIL_LIMIT
|
||||||
|
if blocked:
|
||||||
|
return JSONResponse(status_code=429, content={"detail": "Zu viele fehlgeschlagene Anmeldeversuche"})
|
||||||
|
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if not auth_header:
|
||||||
|
return _unauthorized()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ok = await run_in_threadpool(_check_credentials, auth_header)
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
if ok:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
_register_failure(ip, now)
|
||||||
|
return _unauthorized()
|
||||||
|
|
||||||
|
|
||||||
|
# --- CSRF ---
|
||||||
|
|
||||||
|
_DEFAULT_PORTS = {"http": "80", "https": "443"}
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_host(netloc: str, scheme: str) -> str:
|
||||||
|
netloc = netloc.strip().lower()
|
||||||
|
if not netloc:
|
||||||
|
return ""
|
||||||
|
if netloc.startswith("["):
|
||||||
|
host, _, rest = netloc.partition("]")
|
||||||
|
host += "]"
|
||||||
|
port = rest[1:] if rest.startswith(":") else ""
|
||||||
|
elif ":" in netloc:
|
||||||
|
host, port = netloc.rsplit(":", 1)
|
||||||
|
else:
|
||||||
|
host, port = netloc, ""
|
||||||
|
if not port:
|
||||||
|
port = _DEFAULT_PORTS.get(scheme, "")
|
||||||
|
return f"{host}:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
async def csrf_middleware(request: Request, call_next):
|
||||||
|
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||||
|
if request.headers.get("sec-fetch-site", "").lower() == "cross-site":
|
||||||
|
return JSONResponse(status_code=403, content={"detail": "Cross-Site-Anfrage abgelehnt"})
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
if origin is not None:
|
||||||
|
try:
|
||||||
|
o = urlsplit(origin)
|
||||||
|
origin_host = _norm_host(o.netloc, o.scheme.lower())
|
||||||
|
except ValueError:
|
||||||
|
origin_host = ""
|
||||||
|
candidates = set()
|
||||||
|
for header in ("host", "x-forwarded-host"):
|
||||||
|
value = request.headers.get(header)
|
||||||
|
if value:
|
||||||
|
candidates.add(_norm_host(value.split(",")[0], o.scheme.lower() if origin_host else "http"))
|
||||||
|
if not origin_host or origin_host not in candidates:
|
||||||
|
return JSONResponse(status_code=403, content={"detail": "Ungültiger Origin"})
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Security-Header ---
|
||||||
|
|
||||||
|
SECURITY_HEADERS = {
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
"Content-Security-Policy": (
|
||||||
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "
|
||||||
|
"img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; "
|
||||||
|
"form-action 'self'; object-src 'none'"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def security_headers_middleware(request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
for k, v in SECURITY_HEADERS.items():
|
||||||
|
response.headers[k] = v
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# Zuletzt hinzugefügt = äußerste Schicht
|
||||||
|
app.add_middleware(BaseHTTPMiddleware, dispatch=auth_middleware)
|
||||||
|
app.add_middleware(BaseHTTPMiddleware, dispatch=csrf_middleware)
|
||||||
|
app.add_middleware(BaseHTTPMiddleware, dispatch=security_headers_middleware)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Health ---
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "version": APP_VERSION}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Validierung ---
|
||||||
|
|
||||||
|
StreamType = Literal["auto", "http", "hls", "mp3", "aac", "ogg", "flac", "rtmp"]
|
||||||
|
OutputFormat = Literal["", "auto", "mp3", "aac", "m4a", "mp4", "mkv", "ogg", "opus", "flac", "ts", "wav"]
|
||||||
|
Name = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=200)]
|
||||||
|
Description = Annotated[str, StringConstraints(max_length=2000)]
|
||||||
|
MaxDuration = Optional[Annotated[int, Field(ge=1, le=2592000)]]
|
||||||
|
Buffer = Annotated[int, Field(ge=0, le=120)]
|
||||||
|
|
||||||
|
|
||||||
|
def _bad_request(e: Exception):
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _check_url(url: str) -> str:
|
||||||
|
try:
|
||||||
|
return validate_stream_url(url)
|
||||||
|
except ValueError as e:
|
||||||
|
_bad_request(e)
|
||||||
|
|
||||||
|
|
||||||
|
class StationCreate(BaseModel):
|
||||||
|
name: Name
|
||||||
|
stream_url: Annotated[str, StringConstraints(strip_whitespace=True)]
|
||||||
|
stream_type: StreamType = "auto"
|
||||||
|
description: Description = ""
|
||||||
|
|
||||||
|
|
||||||
|
class StationUpdate(StationCreate):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _station_to_dict(station: Station) -> dict:
|
||||||
|
return {
|
||||||
|
"id": station.id,
|
||||||
|
"name": station.name,
|
||||||
|
"stream_url": station.stream_url,
|
||||||
|
"stream_type": station.stream_type,
|
||||||
|
"description": station.description,
|
||||||
|
"created_at": iso_utc(station.created_at),
|
||||||
|
"updated_at": iso_utc(station.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_station_data(data: StationCreate) -> dict:
|
||||||
|
values = data.model_dump()
|
||||||
|
values["stream_url"] = _check_url(values["stream_url"])
|
||||||
|
values["description"] = (values.get("description") or "").strip()
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
# --- Stations ---
|
||||||
|
|
||||||
|
@app.get("/api/stations")
|
||||||
|
def list_stations():
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
stations = session.query(Station).order_by(Station.name.collate("NOCASE"), Station.id).all()
|
||||||
|
return [_station_to_dict(s) for s in stations]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/stations")
|
||||||
|
def create_station(data: StationCreate):
|
||||||
|
values = _clean_station_data(data)
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
station = Station(**values)
|
||||||
|
session.add(station)
|
||||||
|
session.commit()
|
||||||
|
return {"id": station.id, "message": "Sender gespeichert"}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stations/{station_id}")
|
||||||
|
def get_station(station_id: int):
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
station = session.query(Station).filter_by(id=station_id).first()
|
||||||
|
if not station:
|
||||||
|
raise HTTPException(404, "Sender nicht gefunden")
|
||||||
|
return _station_to_dict(station)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/stations/{station_id}")
|
||||||
|
def update_station(station_id: int, data: StationUpdate):
|
||||||
|
values = _clean_station_data(data)
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
station = session.query(Station).filter_by(id=station_id).first()
|
||||||
|
if not station:
|
||||||
|
raise HTTPException(404, "Sender nicht gefunden")
|
||||||
|
for k, v in values.items():
|
||||||
|
setattr(station, k, v)
|
||||||
|
station.updated_at = utcnow()
|
||||||
|
session.commit()
|
||||||
|
return {"message": "Sender aktualisiert"}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/stations/{station_id}")
|
||||||
|
def delete_station(station_id: int):
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
station = session.query(Station).filter_by(id=station_id).first()
|
||||||
|
if not station:
|
||||||
|
raise HTTPException(404, "Sender nicht gefunden")
|
||||||
|
session.query(Job).filter(Job.station_id == station_id).update(
|
||||||
|
{Job.station_id: None}, synchronize_session=False)
|
||||||
|
session.delete(station)
|
||||||
|
session.commit()
|
||||||
|
return {"message": "Sender gelöscht"}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Jobs ---
|
||||||
|
|
||||||
|
class JobCreate(BaseModel):
|
||||||
|
name: Name
|
||||||
|
stream_url: Annotated[str, StringConstraints(strip_whitespace=True)]
|
||||||
|
stream_type: StreamType = "auto"
|
||||||
|
output_format: OutputFormat = ""
|
||||||
|
max_duration: MaxDuration = None
|
||||||
|
extra_ffmpeg_args: Annotated[str, StringConstraints(strip_whitespace=True)] = ""
|
||||||
|
segment_duration: Optional[int] = None
|
||||||
|
schedule_enabled: bool = False
|
||||||
|
schedule_once: bool = False
|
||||||
|
schedule_date: Annotated[str, StringConstraints(strip_whitespace=True, max_length=10)] = ""
|
||||||
|
schedule_days: Annotated[str, StringConstraints(strip_whitespace=True, max_length=50)] = "*"
|
||||||
|
schedule_start: Annotated[str, StringConstraints(strip_whitespace=True, max_length=5)] = ""
|
||||||
|
schedule_stop: Annotated[str, StringConstraints(strip_whitespace=True, max_length=5)] = ""
|
||||||
|
schedule_end_date: Annotated[str, StringConstraints(strip_whitespace=True, max_length=10)] = ""
|
||||||
|
pre_buffer: Buffer = 0
|
||||||
|
post_buffer: Buffer = 0
|
||||||
|
description: Description = ""
|
||||||
|
ntfy_enabled: bool = False
|
||||||
|
plik_enabled: bool = False
|
||||||
|
delete_after_upload: bool = False
|
||||||
|
station_id: Optional[int] = None
|
||||||
|
|
||||||
|
@field_validator("segment_duration")
|
||||||
|
@classmethod
|
||||||
|
def _segment_duration(cls, v):
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
if v < 10 or v > 86400:
|
||||||
|
raise ValueError("Segmentdauer muss zwischen 10 und 86400 Sekunden liegen")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("schedule_days")
|
||||||
|
@classmethod
|
||||||
|
def _schedule_days(cls, v):
|
||||||
|
return v or "*"
|
||||||
|
|
||||||
|
|
||||||
|
class JobUpdate(JobCreate):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_job(data: JobCreate, session) -> dict:
|
||||||
|
values = data.model_dump()
|
||||||
|
values["stream_url"] = _check_url(values["stream_url"])
|
||||||
|
try:
|
||||||
|
validate_ffmpeg_args(values["extra_ffmpeg_args"])
|
||||||
|
validate_schedule(values)
|
||||||
|
except ValueError as e:
|
||||||
|
_bad_request(e)
|
||||||
|
if values["station_id"] is not None:
|
||||||
|
if not session.query(Station).filter_by(id=values["station_id"]).first():
|
||||||
|
raise HTTPException(400, "Sender nicht gefunden")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _job_active(session, job_id: int) -> bool:
|
||||||
|
return session.query(
|
||||||
|
session.query(RecordingModel).filter(
|
||||||
|
RecordingModel.job_id == job_id,
|
||||||
|
RecordingModel.status.in_(["running", "starting"]),
|
||||||
|
).exists()
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def _job_to_dict(j: Job) -> dict:
|
||||||
|
return {
|
||||||
|
"id": j.id,
|
||||||
|
"name": j.name,
|
||||||
|
"stream_url": j.stream_url,
|
||||||
|
"stream_type": j.stream_type,
|
||||||
|
"output_format": j.output_format,
|
||||||
|
"max_duration": j.max_duration,
|
||||||
|
"extra_ffmpeg_args": j.extra_ffmpeg_args,
|
||||||
|
"segment_duration": j.segment_duration,
|
||||||
|
"schedule_enabled": j.schedule_enabled,
|
||||||
|
"schedule_once": j.schedule_once,
|
||||||
|
"schedule_date": j.schedule_date,
|
||||||
|
"schedule_days": j.schedule_days,
|
||||||
|
"schedule_start": j.schedule_start,
|
||||||
|
"schedule_stop": j.schedule_stop,
|
||||||
|
"schedule_end_date": j.schedule_end_date,
|
||||||
|
"pre_buffer": j.pre_buffer or 0,
|
||||||
|
"post_buffer": j.post_buffer or 0,
|
||||||
|
"description": j.description,
|
||||||
|
"ntfy_enabled": j.ntfy_enabled,
|
||||||
|
"plik_enabled": j.plik_enabled,
|
||||||
|
"delete_after_upload": j.delete_after_upload,
|
||||||
|
"station_id": j.station_id,
|
||||||
|
"created_at": iso_utc(j.created_at),
|
||||||
|
"updated_at": iso_utc(j.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/jobs")
|
||||||
|
def list_jobs():
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
jobs = session.query(Job).order_by(Job.id).all()
|
||||||
|
|
||||||
|
station_ids = {j.station_id for j in jobs if j.station_id}
|
||||||
|
station_names = {}
|
||||||
|
if station_ids:
|
||||||
|
for s in session.query(Station).filter(Station.id.in_(station_ids)).all():
|
||||||
|
station_names[s.id] = s.name
|
||||||
|
|
||||||
|
active_job_ids = {
|
||||||
|
r[0] for r in session.query(RecordingModel.job_id).filter(
|
||||||
|
RecordingModel.job_id.isnot(None),
|
||||||
|
RecordingModel.status.in_(["running", "starting"]),
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for j in jobs:
|
||||||
|
d = _job_to_dict(j)
|
||||||
|
d["station_name"] = station_names.get(j.station_id) if j.station_id else None
|
||||||
|
d["is_recording"] = j.id in active_job_ids
|
||||||
|
result.append(d)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/jobs")
|
||||||
|
def create_job(data: JobCreate):
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
values = _validate_job(data, session)
|
||||||
|
job = Job(**values)
|
||||||
|
session.add(job)
|
||||||
|
session.commit()
|
||||||
|
return {"id": job.id, "message": "Job erstellt"}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/jobs/{job_id}")
|
||||||
|
def get_job(job_id: int):
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
job = session.query(Job).filter_by(id=job_id).first()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "Job nicht gefunden")
|
||||||
|
return _job_to_dict(job)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/jobs/{job_id}")
|
||||||
|
def update_job(job_id: int, data: JobUpdate):
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
job = session.query(Job).filter_by(id=job_id).first()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "Job nicht gefunden")
|
||||||
|
values = _validate_job(data, session)
|
||||||
|
for k, v in values.items():
|
||||||
|
setattr(job, k, v)
|
||||||
|
job.updated_at = utcnow()
|
||||||
|
session.commit()
|
||||||
|
return {"message": "Job aktualisiert"}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/jobs/{job_id}")
|
||||||
|
def delete_job(job_id: int):
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
job = session.query(Job).filter_by(id=job_id).first()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "Job nicht gefunden")
|
||||||
|
if _job_active(session, job_id):
|
||||||
|
raise HTTPException(400, "Job nimmt gerade auf – bitte zuerst stoppen")
|
||||||
|
session.delete(job)
|
||||||
|
session.commit()
|
||||||
|
return {"message": "Job gelöscht"}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
_job_start_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/jobs/{job_id}/record")
|
||||||
|
def start_job_recording(job_id: int):
|
||||||
|
with _job_start_lock:
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
job = session.query(Job).filter_by(id=job_id).first()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "Job nicht gefunden")
|
||||||
|
if _job_active(session, job_id):
|
||||||
|
raise HTTPException(409, "Job nimmt bereits auf")
|
||||||
|
job_dict = _job_to_dict(job)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
duration = compute_duration(job_dict, apply_buffers=False)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
raise HTTPException(400, f"Ungültige Zeitplandaten: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return recorder.start_recording(
|
||||||
|
job_id=job_id,
|
||||||
|
name=job_dict["name"],
|
||||||
|
stream_url=job_dict["stream_url"],
|
||||||
|
stream_type=job_dict["stream_type"],
|
||||||
|
output_format=job_dict["output_format"] or "",
|
||||||
|
max_duration=duration,
|
||||||
|
extra_ffmpeg_args=job_dict["extra_ffmpeg_args"] or "",
|
||||||
|
segment_duration=job_dict["segment_duration"],
|
||||||
|
description=job_dict["description"] or "",
|
||||||
|
ntfy_enabled=job_dict["ntfy_enabled"],
|
||||||
|
plik_enabled=job_dict["plik_enabled"],
|
||||||
|
delete_after_upload=job_dict["delete_after_upload"],
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
_bad_request(e)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Quick Recording ---
|
||||||
|
|
||||||
|
class QuickRecordRequest(BaseModel):
|
||||||
|
url: Annotated[str, StringConstraints(strip_whitespace=True)]
|
||||||
|
name: Annotated[str, StringConstraints(strip_whitespace=True, max_length=200)] = ""
|
||||||
|
stream_type: StreamType = "auto"
|
||||||
|
max_duration: MaxDuration = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/quick-record")
|
||||||
|
def quick_record(data: QuickRecordRequest):
|
||||||
|
url = _check_url(data.url)
|
||||||
|
name = data.name or f"Schnellaufnahme_{datetime.datetime.now().strftime('%H%M%S')}"
|
||||||
|
try:
|
||||||
|
return recorder.start_recording(
|
||||||
|
job_id=None,
|
||||||
|
name=name,
|
||||||
|
stream_url=url,
|
||||||
|
stream_type=data.stream_type,
|
||||||
|
max_duration=data.max_duration,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
_bad_request(e)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Recordings ---
|
||||||
|
|
||||||
|
@app.get("/api/recordings")
|
||||||
|
def list_recordings(active_only: bool = False, limit: int = Query(default=50, ge=1, le=500)):
|
||||||
|
if active_only:
|
||||||
|
return recorder.get_all_active()
|
||||||
|
return recorder.get_all_recordings(limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/recordings/{rec_id}")
|
||||||
|
def get_recording(rec_id: int):
|
||||||
|
status = recorder.get_status(rec_id)
|
||||||
|
if not status:
|
||||||
|
raise HTTPException(404, "Aufnahme nicht gefunden")
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/recordings/{rec_id}/stop")
|
||||||
|
def stop_recording(rec_id: int):
|
||||||
|
status = recorder.get_status(rec_id)
|
||||||
|
if not status:
|
||||||
|
raise HTTPException(404, "Aufnahme nicht gefunden")
|
||||||
|
if status.get("status") not in ("running", "starting"):
|
||||||
|
raise HTTPException(400, "Aufnahme läuft nicht")
|
||||||
|
if status.get("job_id"):
|
||||||
|
scheduler.suppress_current_occurrence(status["job_id"])
|
||||||
|
recorder.stop_recording(rec_id)
|
||||||
|
return {"message": "Aufnahme gestoppt"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/recordings/{rec_id}/extend")
|
||||||
|
def extend_recording(rec_id: int, minutes: int = Query(default=30, ge=1, le=1440)):
|
||||||
|
result = recorder.extend_recording(rec_id, minutes * 60)
|
||||||
|
if result == "not_found":
|
||||||
|
raise HTTPException(404, "Aufnahme nicht gefunden")
|
||||||
|
if result == "unlimited":
|
||||||
|
raise HTTPException(400, "Aufnahme ist bereits unbegrenzt")
|
||||||
|
if result != "ok":
|
||||||
|
raise HTTPException(400, "Aufnahme kann nicht verlängert werden")
|
||||||
|
return {"message": f"Aufnahme um {minutes} Minuten verlängert"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/recordings/{rec_id}/unlimited")
|
||||||
|
def remove_limit(rec_id: int):
|
||||||
|
ok = recorder.remove_duration_limit(rec_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(400, "Zeitlimit kann nicht entfernt werden")
|
||||||
|
return {"message": "Zeitlimit entfernt"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Logs ---
|
||||||
|
|
||||||
|
@app.get("/api/logs")
|
||||||
|
def get_logs(
|
||||||
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
|
level: str = "",
|
||||||
|
):
|
||||||
|
level = level.strip().upper()
|
||||||
|
if level not in ("", "DEBUG", "INFO", "WARN", "WARNING", "ERROR"):
|
||||||
|
raise HTTPException(400, "Ungültiges Log-Level")
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
q = session.query(LogEntry).order_by(LogEntry.id.desc())
|
||||||
|
if level in ("WARN", "WARNING"):
|
||||||
|
q = q.filter(LogEntry.level.in_(["WARN", "WARNING"]))
|
||||||
|
elif level:
|
||||||
|
q = q.filter(LogEntry.level == level)
|
||||||
|
logs = q.limit(limit).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": entry.id,
|
||||||
|
"timestamp": iso_utc(entry.timestamp),
|
||||||
|
"level": entry.level,
|
||||||
|
"message": entry.message,
|
||||||
|
"job_name": entry.job_name,
|
||||||
|
}
|
||||||
|
for entry in logs
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Config ---
|
||||||
|
|
||||||
|
class _StrictModel(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingConfigIn(_StrictModel):
|
||||||
|
download_path: Optional[str] = None
|
||||||
|
max_retries: Optional[Annotated[int, Field(ge=0, le=1000)]] = None
|
||||||
|
retry_delay: Optional[Annotated[int, Field(ge=1, le=3600)]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthConfigIn(_StrictModel):
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
username: Optional[Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=64)]] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NtfyConfigIn(_StrictModel):
|
||||||
|
url: Optional[Annotated[str, StringConstraints(strip_whitespace=True, max_length=1000)]] = None
|
||||||
|
events: Optional[Annotated[str, StringConstraints(strip_whitespace=True, max_length=100)]] = None
|
||||||
|
token: Optional[Annotated[str, StringConstraints(max_length=500)]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlikConfigIn(_StrictModel):
|
||||||
|
url: Optional[Annotated[str, StringConstraints(strip_whitespace=True, max_length=1000)]] = None
|
||||||
|
ttl: Optional[Annotated[str, StringConstraints(strip_whitespace=True, max_length=20)]] = None
|
||||||
|
api_key: Optional[Annotated[str, StringConstraints(max_length=500)]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleConfigIn(_StrictModel):
|
||||||
|
default_pre_buffer: Optional[Buffer] = None
|
||||||
|
default_post_buffer: Optional[Buffer] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LoggingConfigIn(_StrictModel):
|
||||||
|
level: Optional[Literal["DEBUG", "INFO", "WARNING", "ERROR"]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigDataIn(_StrictModel):
|
||||||
|
recording: Optional[RecordingConfigIn] = None
|
||||||
|
auth: Optional[AuthConfigIn] = None
|
||||||
|
ntfy: Optional[NtfyConfigIn] = None
|
||||||
|
plik: Optional[PlikConfigIn] = None
|
||||||
|
schedule: Optional[ScheduleConfigIn] = None
|
||||||
|
logging: Optional[LoggingConfigIn] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigUpdate(_StrictModel):
|
||||||
|
data: ConfigDataIn
|
||||||
|
|
||||||
|
|
||||||
|
NTFY_EVENTS = {"start", "stop", "error", "upload"}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_http_url(url: str, label: str):
|
||||||
|
if not url:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
parts = urlsplit(url)
|
||||||
|
hostname = parts.hostname
|
||||||
|
except ValueError:
|
||||||
|
hostname = None
|
||||||
|
parts = None
|
||||||
|
if not parts or parts.scheme not in ("http", "https") or not hostname or any(c.isspace() for c in url):
|
||||||
|
raise HTTPException(400, f"{label}-URL muss eine gültige http(s)-URL sein")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/config")
|
||||||
|
def get_config():
|
||||||
|
with config.lock:
|
||||||
|
g = config.get
|
||||||
|
return {
|
||||||
|
"recording": {
|
||||||
|
"download_path": g("recording", "download_path"),
|
||||||
|
"max_retries": g("recording", "max_retries"),
|
||||||
|
"retry_delay": g("recording", "retry_delay"),
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"host": g("server", "host"),
|
||||||
|
"port": g("server", "port"),
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"enabled": bool(g("auth", "enabled", default=False)),
|
||||||
|
"username": g("auth", "username", default="admin"),
|
||||||
|
"password_set": bool(g("auth", "password_hash", default="")),
|
||||||
|
},
|
||||||
|
"ntfy": {
|
||||||
|
"url": g("ntfy", "url", default=""),
|
||||||
|
"events": g("ntfy", "events", default=""),
|
||||||
|
"token_set": bool(g("ntfy", "token", default="")),
|
||||||
|
},
|
||||||
|
"plik": {
|
||||||
|
"url": g("plik", "url", default=""),
|
||||||
|
"ttl": g("plik", "ttl", default=""),
|
||||||
|
"api_key_set": bool(g("plik", "api_key", default="")),
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"default_pre_buffer": g("schedule", "default_pre_buffer"),
|
||||||
|
"default_post_buffer": g("schedule", "default_post_buffer"),
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": g("logging", "level", default="INFO"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/config")
|
||||||
|
def update_config(payload: ConfigUpdate):
|
||||||
|
sections = {
|
||||||
|
name: {k: v for k, v in section.model_dump(exclude_unset=True).items() if v is not None}
|
||||||
|
for name, section in payload.data
|
||||||
|
if section is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = sections.get("recording", {})
|
||||||
|
if "download_path" in rec:
|
||||||
|
try:
|
||||||
|
rec["download_path"] = validate_download_path(rec["download_path"])
|
||||||
|
except ValueError as e:
|
||||||
|
_bad_request(e)
|
||||||
|
|
||||||
|
ntfy = sections.get("ntfy", {})
|
||||||
|
if "url" in ntfy:
|
||||||
|
_check_http_url(ntfy["url"], "NTFY")
|
||||||
|
if "events" in ntfy:
|
||||||
|
events = [e.strip() for e in ntfy["events"].split(",") if e.strip()]
|
||||||
|
invalid = [e for e in events if e not in NTFY_EVENTS]
|
||||||
|
if invalid:
|
||||||
|
raise HTTPException(400, f"Unbekannte NTFY-Ereignisse: {', '.join(invalid)}")
|
||||||
|
ntfy["events"] = ",".join(events)
|
||||||
|
|
||||||
|
plik = sections.get("plik", {})
|
||||||
|
if "url" in plik:
|
||||||
|
_check_http_url(plik["url"], "Plik")
|
||||||
|
if "ttl" in plik and plik["ttl"] and not re.fullmatch(r"\d+[dh]?", plik["ttl"]):
|
||||||
|
raise HTTPException(400, "Plik-TTL muss z.B. 30d, 12h oder eine Sekundenzahl sein")
|
||||||
|
|
||||||
|
auth = sections.get("auth", {})
|
||||||
|
new_password = auth.pop("password", None)
|
||||||
|
if new_password is not None:
|
||||||
|
if new_password == "":
|
||||||
|
raise HTTPException(400, "Passwort darf nicht leer sein")
|
||||||
|
if len(new_password) < 8:
|
||||||
|
raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein")
|
||||||
|
if len(new_password) > 1024:
|
||||||
|
raise HTTPException(400, "Passwort ist zu lang")
|
||||||
|
|
||||||
|
with config.lock:
|
||||||
|
will_enable = auth.get("enabled", config.get("auth", "enabled", default=False))
|
||||||
|
has_password = bool(new_password) or bool(config.get("auth", "password_hash", default=""))
|
||||||
|
if will_enable and not has_password:
|
||||||
|
raise HTTPException(400, "Zum Aktivieren der Anmeldung muss ein Passwort gesetzt sein")
|
||||||
|
|
||||||
|
auth_changed = new_password is not None or "username" in auth
|
||||||
|
if new_password is not None:
|
||||||
|
auth["password_hash"] = hash_password(new_password)
|
||||||
|
|
||||||
|
for name, values in sections.items():
|
||||||
|
config.data.setdefault(name, {}).update(values)
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
if auth_changed:
|
||||||
|
clear_auth_cache()
|
||||||
|
if "level" in sections.get("logging", {}):
|
||||||
|
_apply_log_level(sections["logging"]["level"])
|
||||||
|
return {"message": "Konfiguration gespeichert"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Static files (Frontend) ---
|
||||||
|
|
||||||
|
if os.path.isdir(STATIC_DIR):
|
||||||
|
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
host = config.get("server", "host", default="0.0.0.0")
|
||||||
|
port = config.get("server", "port", default=8484)
|
||||||
|
uvicorn.run("app:app", host=host, port=port, reload=False)
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
DEFAULT_DOWNLOAD_PATH = "/app/data/recordings"
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
"recording": {
|
||||||
|
"download_path": DEFAULT_DOWNLOAD_PATH,
|
||||||
|
"max_retries": 5,
|
||||||
|
"retry_delay": 5,
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8484,
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"enabled": False,
|
||||||
|
"username": "admin",
|
||||||
|
"password_hash": "",
|
||||||
|
},
|
||||||
|
"ntfy": {
|
||||||
|
"url": "",
|
||||||
|
"token": "",
|
||||||
|
"events": "error",
|
||||||
|
},
|
||||||
|
"plik": {
|
||||||
|
"url": "",
|
||||||
|
"api_key": "",
|
||||||
|
"ttl": "30d",
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"default_pre_buffer": 5,
|
||||||
|
"default_post_buffer": 5,
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "INFO",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
PBKDF2_ITERATIONS = 600000
|
||||||
|
|
||||||
|
|
||||||
|
LEGACY_DEFAULT_PASSWORD = "stream-recorder"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str, iterations: int | None = None) -> str:
|
||||||
|
iterations = iterations or PBKDF2_ITERATIONS
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
||||||
|
return "pbkdf2_sha256${}${}${}".format(
|
||||||
|
iterations,
|
||||||
|
base64.b64encode(salt).decode("ascii"),
|
||||||
|
base64.b64encode(dk).decode("ascii"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, encoded: str) -> bool:
|
||||||
|
try:
|
||||||
|
algo, iterations, salt_b64, hash_b64 = encoded.split("$", 3)
|
||||||
|
if algo != "pbkdf2_sha256":
|
||||||
|
return False
|
||||||
|
salt = base64.b64decode(salt_b64)
|
||||||
|
expected = base64.b64decode(hash_b64)
|
||||||
|
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations))
|
||||||
|
return hmac.compare_digest(dk, expected)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def recording_roots() -> list[str]:
|
||||||
|
raw = os.environ.get("RECORDING_ROOTS", os.pathsep.join(["/app/data/recordings", "/recordings"]))
|
||||||
|
return [os.path.realpath(r) for r in raw.split(os.pathsep) if r.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_download_path(path: str) -> str:
|
||||||
|
"""Gibt den normalisierten Pfad zurück oder wirft ValueError."""
|
||||||
|
if not isinstance(path, str) or not path.strip():
|
||||||
|
raise ValueError("Download-Pfad darf nicht leer sein")
|
||||||
|
if "\x00" in path:
|
||||||
|
raise ValueError("Download-Pfad enthält ungültige Zeichen")
|
||||||
|
if not os.path.isabs(path):
|
||||||
|
raise ValueError("Download-Pfad muss absolut sein")
|
||||||
|
real = os.path.realpath(path)
|
||||||
|
roots = recording_roots()
|
||||||
|
for root in roots:
|
||||||
|
if real == root or real.startswith(root.rstrip(os.sep) + os.sep):
|
||||||
|
return real
|
||||||
|
raise ValueError(f"Download-Pfad muss innerhalb von {', '.join(roots)} liegen")
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
def __init__(self, config_path: str = "/app/data/config.yml"):
|
||||||
|
self.config_path = config_path
|
||||||
|
self.data = {}
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.generated_password = None
|
||||||
|
self.password_migrated = False
|
||||||
|
self.default_password_active = False
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
with self.lock:
|
||||||
|
self.data = _deep_copy(DEFAULT_CONFIG)
|
||||||
|
if os.path.exists(self.config_path):
|
||||||
|
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||||
|
user_config = yaml.safe_load(f) or {}
|
||||||
|
_deep_merge(self.data, user_config)
|
||||||
|
auth = self.data.setdefault("auth", {})
|
||||||
|
if "password" in auth:
|
||||||
|
plain = auth.pop("password")
|
||||||
|
if plain and not auth.get("password_hash"):
|
||||||
|
auth["password_hash"] = hash_password(str(plain))
|
||||||
|
self.password_migrated = True
|
||||||
|
self.save()
|
||||||
|
# Altes Standardpasswort gilt als nicht gesetzt, sonst würde
|
||||||
|
# "Auth aktivieren" ohne neues Passwort ein bekanntes Passwort scharf schalten.
|
||||||
|
pw_hash = auth.get("password_hash")
|
||||||
|
if pw_hash and verify_password(LEGACY_DEFAULT_PASSWORD, pw_hash):
|
||||||
|
if auth.get("enabled"):
|
||||||
|
self.default_password_active = True
|
||||||
|
else:
|
||||||
|
auth["password_hash"] = ""
|
||||||
|
self.save()
|
||||||
|
else:
|
||||||
|
password = secrets.token_urlsafe(12)
|
||||||
|
self.data["auth"]["enabled"] = True
|
||||||
|
self.data["auth"]["username"] = "admin"
|
||||||
|
self.data["auth"]["password_hash"] = hash_password(password)
|
||||||
|
self.generated_password = password
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
with self.lock:
|
||||||
|
directory = os.path.dirname(self.config_path) or "."
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
fd, tmp_path = tempfile.mkstemp(prefix=".config.", suffix=".tmp", dir=directory)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.chmod(tmp_path, 0o600)
|
||||||
|
os.replace(tmp_path, self.config_path)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get(self, *keys, default=None):
|
||||||
|
with self.lock:
|
||||||
|
val = self.data
|
||||||
|
for key in keys:
|
||||||
|
if isinstance(val, dict) and key in val:
|
||||||
|
val = val[key]
|
||||||
|
else:
|
||||||
|
return default
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_copy(d):
|
||||||
|
if isinstance(d, dict):
|
||||||
|
return {k: _deep_copy(v) for k, v in d.items()}
|
||||||
|
if isinstance(d, list):
|
||||||
|
return [_deep_copy(v) for v in d]
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_merge(base, override):
|
||||||
|
for key, value in override.items():
|
||||||
|
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
||||||
|
_deep_merge(base[key], value)
|
||||||
|
else:
|
||||||
|
base[key] = value
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event, Column, Integer, String, Boolean, DateTime, Text, text
|
||||||
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
logger = logging.getLogger("models")
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime.datetime:
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def iso_utc(dt: datetime.datetime | None) -> str | None:
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
return dt.astimezone(datetime.timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class Job(Base):
|
||||||
|
__tablename__ = "jobs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
stream_url = Column(String(1000), nullable=False)
|
||||||
|
stream_type = Column(String(20), default="auto")
|
||||||
|
output_format = Column(String(10), default="")
|
||||||
|
max_duration = Column(Integer, nullable=True)
|
||||||
|
extra_ffmpeg_args = Column(String(500), default="")
|
||||||
|
segment_duration = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
schedule_enabled = Column(Boolean, default=False)
|
||||||
|
schedule_once = Column(Boolean, default=False)
|
||||||
|
schedule_date = Column(String(10), default="")
|
||||||
|
schedule_days = Column(String(50), default="*")
|
||||||
|
schedule_start = Column(String(5), default="")
|
||||||
|
schedule_stop = Column(String(5), default="")
|
||||||
|
schedule_end_date = Column(String(10), default="")
|
||||||
|
|
||||||
|
pre_buffer = Column(Integer, default=0)
|
||||||
|
post_buffer = Column(Integer, default=0)
|
||||||
|
|
||||||
|
description = Column(Text, default="")
|
||||||
|
|
||||||
|
plik_enabled = Column(Boolean, default=False)
|
||||||
|
delete_after_upload = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
ntfy_enabled = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
station_id = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=utcnow)
|
||||||
|
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Station(Base):
|
||||||
|
__tablename__ = "stations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
stream_url = Column(String(1000), nullable=False)
|
||||||
|
stream_type = Column(String(20), default="auto")
|
||||||
|
description = Column(Text, default="")
|
||||||
|
created_at = Column(DateTime, default=utcnow)
|
||||||
|
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Recording(Base):
|
||||||
|
__tablename__ = "recordings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
job_id = Column(Integer, nullable=True)
|
||||||
|
job_name = Column(String(200), nullable=False)
|
||||||
|
stream_url = Column(String(1000), nullable=False)
|
||||||
|
stream_type = Column(String(20), default="")
|
||||||
|
output_dir = Column(String(500), default="")
|
||||||
|
output_file = Column(String(500), default="")
|
||||||
|
pid = Column(Integer, nullable=True)
|
||||||
|
status = Column(String(20), default="running") # running, stopped, completed, error
|
||||||
|
started_at = Column(DateTime, default=utcnow)
|
||||||
|
stopped_at = Column(DateTime, nullable=True)
|
||||||
|
scheduled_stop = Column(DateTime, nullable=True)
|
||||||
|
segment_mode = Column(Boolean, default=False)
|
||||||
|
is_scheduled = Column(Boolean, default=False)
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
file_size = Column(Integer, nullable=True)
|
||||||
|
plik_url = Column(String(1000), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LogEntry(Base):
|
||||||
|
__tablename__ = "logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
timestamp = Column(DateTime, default=utcnow)
|
||||||
|
level = Column(String(10), default="INFO")
|
||||||
|
message = Column(Text, nullable=False)
|
||||||
|
job_name = Column(String(200), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
_engine = None
|
||||||
|
_SessionLocal = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_path: str):
|
||||||
|
global _engine, _SessionLocal
|
||||||
|
_engine = create_engine(
|
||||||
|
f"sqlite:///{db_path}",
|
||||||
|
connect_args={"check_same_thread": False, "timeout": 30},
|
||||||
|
)
|
||||||
|
|
||||||
|
@event.listens_for(_engine, "connect")
|
||||||
|
def _set_sqlite_pragmas(dbapi_conn, _record):
|
||||||
|
cur = dbapi_conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cur.execute("PRAGMA busy_timeout=5000")
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
Base.metadata.create_all(_engine)
|
||||||
|
_SessionLocal = sessionmaker(bind=_engine)
|
||||||
|
_migrate(_engine)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate(engine):
|
||||||
|
with engine.connect() as conn:
|
||||||
|
for stmt in [
|
||||||
|
"ALTER TABLE recordings ADD COLUMN file_size INTEGER",
|
||||||
|
"ALTER TABLE jobs ADD COLUMN station_id INTEGER",
|
||||||
|
"ALTER TABLE jobs ADD COLUMN pre_buffer INTEGER DEFAULT 0",
|
||||||
|
"ALTER TABLE jobs ADD COLUMN post_buffer INTEGER DEFAULT 0",
|
||||||
|
"ALTER TABLE recordings ADD COLUMN plik_url VARCHAR(1000)",
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
conn.execute(text(stmt))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_session():
|
||||||
|
return _SessionLocal()
|
||||||
|
|
||||||
|
|
||||||
|
def prune_logs(days: int) -> int:
|
||||||
|
if days <= 0:
|
||||||
|
return 0
|
||||||
|
cutoff = utcnow() - datetime.timedelta(days=days)
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
count = session.query(LogEntry).filter(LogEntry.timestamp < cutoff).delete(synchronize_session=False)
|
||||||
|
session.commit()
|
||||||
|
if count:
|
||||||
|
logger.info("%d alte Log-Einträge gelöscht (älter als %d Tage)", count, days)
|
||||||
|
return count
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error("Log-Bereinigung fehlgeschlagen: %s", e)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""NTFY push notification plugin."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger("plugins.ntfy")
|
||||||
|
|
||||||
|
EVENT_TAGS = {
|
||||||
|
"start": "red_circle",
|
||||||
|
"stop": "white_check_mark",
|
||||||
|
"error": "rotating_light",
|
||||||
|
"upload": "outbox_tray",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _post(url: str, title: str, message: str, token: str, priority: str, tags: str):
|
||||||
|
params = {"title": title, "priority": priority or "default"}
|
||||||
|
if tags:
|
||||||
|
params["tags"] = tags
|
||||||
|
headers = {"Content-Type": "text/plain; charset=utf-8"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
try:
|
||||||
|
resp = httpx.post(url, params=params, content=message.encode("utf-8"),
|
||||||
|
headers=headers, timeout=10.0)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
logger.warning("NTFY-Benachrichtigung fehlgeschlagen: HTTP %s %s",
|
||||||
|
resp.status_code, resp.text[:200])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("NTFY-Benachrichtigung fehlgeschlagen: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def send_notification(
|
||||||
|
url: str,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
token: str = "",
|
||||||
|
priority: str = "default",
|
||||||
|
tags: str = "",
|
||||||
|
):
|
||||||
|
if not url:
|
||||||
|
return
|
||||||
|
threading.Thread(
|
||||||
|
target=_post,
|
||||||
|
args=(url, title, message, token, priority, tags),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
|
class NtfyNotifier:
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def _should_notify(self, event: str, job_ntfy_enabled: bool) -> bool:
|
||||||
|
if not job_ntfy_enabled:
|
||||||
|
return False
|
||||||
|
url = self.config.get("ntfy", "url", default="")
|
||||||
|
if not url:
|
||||||
|
return False
|
||||||
|
events = self.config.get("ntfy", "events", default="error") or ""
|
||||||
|
return event in [e.strip() for e in events.split(",")]
|
||||||
|
|
||||||
|
def notify(self, event: str, title: str, message: str, job_ntfy_enabled: bool = False, priority: str = "default"):
|
||||||
|
if not self._should_notify(event, job_ntfy_enabled):
|
||||||
|
return
|
||||||
|
url = self.config.get("ntfy", "url", default="")
|
||||||
|
token = self.config.get("ntfy", "token", default="")
|
||||||
|
tag = EVENT_TAGS.get(event, "")
|
||||||
|
send_notification(url, title, message, token=token, priority=priority, tags=tag)
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""Plik file upload plugin."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
UPLOAD_TIMEOUT = httpx.Timeout(7200.0, connect=30.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ttl_seconds(ttl: str) -> int | None:
|
||||||
|
if not ttl:
|
||||||
|
return None
|
||||||
|
m = re.match(r"^(\d+)d$", ttl)
|
||||||
|
if m:
|
||||||
|
return int(m.group(1)) * 86400
|
||||||
|
m = re.match(r"^(\d+)h$", ttl)
|
||||||
|
if m:
|
||||||
|
return int(m.group(1)) * 3600
|
||||||
|
m = re.match(r"^(\d+)$", ttl)
|
||||||
|
if m:
|
||||||
|
return int(m.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_response(body: str, plik_base: str, filename: str) -> dict:
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
data = None
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if "error" in data:
|
||||||
|
return {"error": f"Plik-Server: {data['error']}"}
|
||||||
|
upload_id = data.get("id", "")
|
||||||
|
if not upload_id:
|
||||||
|
return {"error": f"Keine Upload-ID in Antwort: {body[:300]}"}
|
||||||
|
|
||||||
|
browser_url = f"{plik_base}/#/?id={upload_id}"
|
||||||
|
download_url = None
|
||||||
|
files = data.get("files", [])
|
||||||
|
if files:
|
||||||
|
file_id = files[0].get("id", "")
|
||||||
|
file_name = files[0].get("fileName", filename)
|
||||||
|
if file_id:
|
||||||
|
download_url = f"{plik_base}/file/{upload_id}/{file_id}/{file_name}"
|
||||||
|
return {"browser_url": browser_url, "download_url": download_url}
|
||||||
|
|
||||||
|
urls = re.findall(r'https?://\S+', body)
|
||||||
|
if urls:
|
||||||
|
download_url = urls[-1]
|
||||||
|
# Plik baut die URL aus DownloadDomain bzw. dem Host-Header. Ohne DownloadDomain
|
||||||
|
# hinter einem Reverse-Proxy kommt dabei z.B. 127.0.0.1:3000 heraus, daher den
|
||||||
|
# /file/-Pfad immer auf die konfigurierte Plik-URL umschreiben.
|
||||||
|
idx = download_url.find("/file/")
|
||||||
|
if idx != -1:
|
||||||
|
file_path = download_url[idx + len("/file/"):]
|
||||||
|
uid = file_path.split("/")[0]
|
||||||
|
if uid:
|
||||||
|
download_url = f"{plik_base}/file/{file_path}"
|
||||||
|
return {"browser_url": f"{plik_base}/#/?id={uid}", "download_url": download_url}
|
||||||
|
return {"browser_url": download_url, "download_url": download_url}
|
||||||
|
return {"error": f"Antwort nicht lesbar: {body[:300]}"}
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(
|
||||||
|
file_path: str,
|
||||||
|
plik_url: str,
|
||||||
|
api_key: str = "",
|
||||||
|
ttl: str = "30d",
|
||||||
|
) -> dict | None:
|
||||||
|
"""Upload a file to Plik. Returns dict with 'browser_url', 'download_url', or {'error': reason} on failure."""
|
||||||
|
if not plik_url:
|
||||||
|
return {"error": "Keine Plik-URL konfiguriert"}
|
||||||
|
if not os.path.isfile(file_path):
|
||||||
|
return {"error": f"Datei nicht gefunden: {file_path}"}
|
||||||
|
if os.path.getsize(file_path) == 0:
|
||||||
|
return {"error": "Datei ist leer"}
|
||||||
|
|
||||||
|
plik_base = plik_url.rstrip("/")
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if api_key:
|
||||||
|
headers["X-PlikToken"] = api_key
|
||||||
|
|
||||||
|
form = {}
|
||||||
|
ttl_secs = _parse_ttl_seconds(ttl)
|
||||||
|
if ttl_secs is not None:
|
||||||
|
form["ttl"] = str(ttl_secs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as fh:
|
||||||
|
resp = httpx.post(
|
||||||
|
plik_base,
|
||||||
|
headers=headers,
|
||||||
|
data=form,
|
||||||
|
files={"file": (filename, fh, "application/octet-stream")},
|
||||||
|
timeout=UPLOAD_TIMEOUT,
|
||||||
|
)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return {"error": f"HTTP {resp.status_code}: {resp.text.strip()[:500]}"}
|
||||||
|
return _parse_response(resp.text, plik_base, filename)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return {"error": "Upload-Timeout (max. 2h)"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
class PlikUploader:
|
||||||
|
def __init__(self, config, log_fn: Callable, notify_fn: Callable = None):
|
||||||
|
self.config = config
|
||||||
|
self._log = log_fn
|
||||||
|
self._notify = notify_fn
|
||||||
|
|
||||||
|
def upload_recording(self, files: list[str], job_name: str, segment_mode: bool,
|
||||||
|
delete_after: bool, ntfy_enabled: bool, save_url_fn: Callable = None):
|
||||||
|
plik_url = self.config.get("plik", "url", default="")
|
||||||
|
if not plik_url:
|
||||||
|
self._log("WARN", f"Plik-Upload übersprungen: Keine URL konfiguriert [{job_name}]", job_name)
|
||||||
|
return
|
||||||
|
|
||||||
|
api_key = self.config.get("plik", "api_key", default="")
|
||||||
|
ttl = self.config.get("plik", "ttl", default="30d")
|
||||||
|
files = [f for f in files if not os.path.basename(f).startswith(".")]
|
||||||
|
|
||||||
|
def _do_upload():
|
||||||
|
try:
|
||||||
|
self._upload_files(files, job_name, segment_mode, plik_url, api_key, ttl,
|
||||||
|
delete_after, ntfy_enabled, save_url_fn)
|
||||||
|
except Exception as e:
|
||||||
|
self._log("ERROR", f"Plik-Upload Fehler: {e} [{job_name}]", job_name)
|
||||||
|
|
||||||
|
threading.Thread(target=_do_upload, daemon=True).start()
|
||||||
|
|
||||||
|
def _remove(self, path: str, job_name: str):
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
self._log("INFO", f"Lokale Datei gelöscht: {os.path.basename(path)} [{job_name}]", job_name)
|
||||||
|
except OSError as e:
|
||||||
|
self._log("ERROR", f"Lokale Datei konnte nicht gelöscht werden: {path} - {e} [{job_name}]", job_name)
|
||||||
|
|
||||||
|
def _upload_files(self, files, job_name, segment_mode, plik_url, api_key, ttl,
|
||||||
|
delete_after, ntfy_enabled, save_url_fn=None):
|
||||||
|
files = [f for f in files if os.path.isfile(f) and os.path.getsize(f) > 0]
|
||||||
|
if not files:
|
||||||
|
self._log("WARN", f"Plik-Upload übersprungen: Datei fehlt oder leer [{job_name}]", job_name)
|
||||||
|
return
|
||||||
|
|
||||||
|
label = "Segment" if segment_mode else "Teil"
|
||||||
|
multi = len(files) > 1
|
||||||
|
uploaded = 0
|
||||||
|
last_browser_url = None
|
||||||
|
for fpath in files:
|
||||||
|
fname = os.path.basename(fpath)
|
||||||
|
prefix = f"Plik {label}-Upload" if multi else "Plik-Upload"
|
||||||
|
self._log("INFO", f"{prefix}: {fname} [{job_name}]", job_name)
|
||||||
|
result = upload_file(fpath, plik_url, api_key, ttl)
|
||||||
|
if result and "error" in result:
|
||||||
|
self._log("ERROR", f"{prefix} fehlgeschlagen: {fname} - {result['error']} [{job_name}]", job_name)
|
||||||
|
elif result and result.get("browser_url"):
|
||||||
|
last_browser_url = result["browser_url"]
|
||||||
|
self._log("INFO", f"{prefix} abgeschlossen: {last_browser_url} [{job_name}]", job_name)
|
||||||
|
if result.get("download_url"):
|
||||||
|
self._log("INFO", f"Plik-Download: {result['download_url']} [{job_name}]", job_name)
|
||||||
|
uploaded += 1
|
||||||
|
if delete_after:
|
||||||
|
self._remove(fpath, job_name)
|
||||||
|
else:
|
||||||
|
self._log("ERROR", f"{prefix} fehlgeschlagen: {fname} (unbekannter Fehler) [{job_name}]", job_name)
|
||||||
|
|
||||||
|
if uploaded > 0:
|
||||||
|
if save_url_fn and last_browser_url:
|
||||||
|
save_url_fn(last_browser_url)
|
||||||
|
if self._notify:
|
||||||
|
if multi:
|
||||||
|
msg = f"{job_name}: {uploaded} {label}(e) hochgeladen"
|
||||||
|
if last_browser_url:
|
||||||
|
msg += f"\n{last_browser_url}"
|
||||||
|
else:
|
||||||
|
msg = f"{job_name}: {last_browser_url}"
|
||||||
|
self._notify("upload", "Plik-Upload", msg, ntfy_enabled)
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
import datetime
|
||||||
|
import glob as globmod
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from 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:
|
||||||
|
url_lower = url.lower().split("?")[0]
|
||||||
|
if url_lower.startswith(("rtmp://", "rtmps://")):
|
||||||
|
return "rtmp"
|
||||||
|
if url_lower.endswith(".m3u8"):
|
||||||
|
return "hls"
|
||||||
|
if url_lower.endswith(".mp3"):
|
||||||
|
return "mp3"
|
||||||
|
if url_lower.endswith(".aac"):
|
||||||
|
return "aac"
|
||||||
|
if url_lower.endswith(".ogg"):
|
||||||
|
return "ogg"
|
||||||
|
if url_lower.endswith(".flac"):
|
||||||
|
return "flac"
|
||||||
|
return "http"
|
||||||
|
|
||||||
|
|
||||||
|
def default_extension(stream_type: str) -> str:
|
||||||
|
return {"mp3": "mp3", "aac": "aac", "ogg": "ogg", "flac": "flac"}.get(stream_type, "mp4")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_name(name: str) -> str:
|
||||||
|
return re.sub(r"[^A-Za-z0-9_-]", "_", name).strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def format_elapsed(seconds: int) -> str:
|
||||||
|
d, rem = divmod(seconds, 86400)
|
||||||
|
h, rem = divmod(rem, 3600)
|
||||||
|
m, s = divmod(rem, 60)
|
||||||
|
if d > 0:
|
||||||
|
return f"{d}d {h}h {m}m"
|
||||||
|
if h > 0:
|
||||||
|
return f"{h}h {m}m {s}s"
|
||||||
|
return f"{m}m {s}s"
|
||||||
|
|
||||||
|
|
||||||
|
def format_size(size_bytes: int) -> str:
|
||||||
|
if size_bytes >= 1073741824:
|
||||||
|
return f"{size_bytes / 1073741824:.1f} GB"
|
||||||
|
if size_bytes >= 1048576:
|
||||||
|
return f"{size_bytes / 1048576:.1f} MB"
|
||||||
|
if size_bytes >= 1024:
|
||||||
|
return f"{size_bytes / 1024:.1f} KB"
|
||||||
|
return f"{size_bytes} B"
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingManager:
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
self._processes: dict[int, subprocess.Popen] = {}
|
||||||
|
self._stop_events: dict[int, threading.Event] = {}
|
||||||
|
self._threads: dict[int, threading.Thread] = {}
|
||||||
|
self._ntfy = None
|
||||||
|
self._plik = None
|
||||||
|
self._rec_options: dict[int, dict] = {}
|
||||||
|
self._output_files: dict[int, list[str]] = {}
|
||||||
|
|
||||||
|
def _get_ntfy(self):
|
||||||
|
if self._ntfy is None:
|
||||||
|
from plugins.ntfy import NtfyNotifier
|
||||||
|
self._ntfy = NtfyNotifier(self.config)
|
||||||
|
return self._ntfy
|
||||||
|
|
||||||
|
def _get_plik(self):
|
||||||
|
if self._plik is None:
|
||||||
|
from plugins.plik import PlikUploader
|
||||||
|
self._plik = PlikUploader(
|
||||||
|
self.config,
|
||||||
|
log_fn=self._log,
|
||||||
|
notify_fn=lambda event, title, msg, ntfy_en: self._get_ntfy().notify(event, title, msg, ntfy_en),
|
||||||
|
)
|
||||||
|
return self._plik
|
||||||
|
|
||||||
|
def _notify(self, event: str, title: str, message: str, rec_id: int = None, priority: str = "default"):
|
||||||
|
ntfy_enabled = False
|
||||||
|
if rec_id and rec_id in self._rec_options:
|
||||||
|
ntfy_enabled = self._rec_options[rec_id].get("ntfy_enabled", False)
|
||||||
|
desc = self._rec_options[rec_id].get("description", "")
|
||||||
|
if desc:
|
||||||
|
message = f"{message}\n{desc}"
|
||||||
|
self._get_ntfy().notify(event, title, message, job_ntfy_enabled=ntfy_enabled, priority=priority)
|
||||||
|
|
||||||
|
def _log(self, level: str, message: str, job_name: str = None):
|
||||||
|
py_level = logging.WARNING if level == "WARN" else getattr(logging, level, logging.INFO)
|
||||||
|
logger.log(py_level, message)
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
entry = LogEntry(level=level, message=message, job_name=job_name)
|
||||||
|
session.add(entry)
|
||||||
|
session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error("Log-Eintrag konnte nicht gespeichert werden: %s", e)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def _download_path(self) -> str:
|
||||||
|
configured = self.config.get("recording", "download_path", default=DEFAULT_DOWNLOAD_PATH)
|
||||||
|
try:
|
||||||
|
return validate_download_path(configured)
|
||||||
|
except ValueError as e:
|
||||||
|
self._log("ERROR", f"Ungültiger Download-Pfad '{configured}': {e} – verwende {DEFAULT_DOWNLOAD_PATH}")
|
||||||
|
return DEFAULT_DOWNLOAD_PATH
|
||||||
|
|
||||||
|
def start_recording(
|
||||||
|
self,
|
||||||
|
job_id: Optional[int],
|
||||||
|
name: str,
|
||||||
|
stream_url: str,
|
||||||
|
stream_type: str = "auto",
|
||||||
|
output_format: str = "",
|
||||||
|
max_duration: Optional[int] = None,
|
||||||
|
extra_ffmpeg_args: str = "",
|
||||||
|
segment_duration: Optional[int] = None,
|
||||||
|
is_scheduled: bool = False,
|
||||||
|
description: str = "",
|
||||||
|
ntfy_enabled: bool = False,
|
||||||
|
plik_enabled: bool = False,
|
||||||
|
delete_after_upload: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
stream_url = validate_stream_url(stream_url)
|
||||||
|
validate_ffmpeg_args(extra_ffmpeg_args or "")
|
||||||
|
|
||||||
|
if not stream_type or stream_type == "auto":
|
||||||
|
stream_type = detect_stream_type(stream_url)
|
||||||
|
|
||||||
|
ext = output_format if output_format and output_format.lower() != "auto" else default_extension(stream_type)
|
||||||
|
sname = safe_name(name)
|
||||||
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
download_path = self._download_path()
|
||||||
|
output_dir = os.path.join(download_path, sname)
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
segment_mode = segment_duration is not None and segment_duration > 0
|
||||||
|
|
||||||
|
if segment_mode:
|
||||||
|
output_file = os.path.join(output_dir, f"{sname}_{timestamp}_seg%03d.{ext}")
|
||||||
|
else:
|
||||||
|
output_file = os.path.join(output_dir, f"{sname}_{timestamp}.{ext}")
|
||||||
|
|
||||||
|
scheduled_stop = None
|
||||||
|
if max_duration and max_duration > 0:
|
||||||
|
scheduled_stop = utcnow() + datetime.timedelta(seconds=max_duration)
|
||||||
|
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
recording = Recording(
|
||||||
|
job_id=job_id,
|
||||||
|
job_name=name,
|
||||||
|
stream_url=stream_url,
|
||||||
|
stream_type=stream_type,
|
||||||
|
output_dir=output_dir,
|
||||||
|
output_file=output_file,
|
||||||
|
status="starting",
|
||||||
|
scheduled_stop=scheduled_stop,
|
||||||
|
segment_mode=segment_mode,
|
||||||
|
is_scheduled=is_scheduled,
|
||||||
|
)
|
||||||
|
session.add(recording)
|
||||||
|
session.commit()
|
||||||
|
rec_id = recording.id
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
self._rec_options[rec_id] = {
|
||||||
|
"ntfy_enabled": ntfy_enabled,
|
||||||
|
"plik_enabled": plik_enabled,
|
||||||
|
"delete_after_upload": delete_after_upload,
|
||||||
|
"segment_mode": segment_mode,
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
self._output_files[rec_id] = [output_file]
|
||||||
|
|
||||||
|
stop_event = threading.Event()
|
||||||
|
self._stop_events[rec_id] = stop_event
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=self._recording_loop,
|
||||||
|
args=(rec_id, stream_url, stream_type, output_file, output_dir,
|
||||||
|
extra_ffmpeg_args, segment_mode, segment_duration, stop_event, name),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._threads[rec_id] = thread
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
self._log("INFO", f"Aufnahme gestartet: {name}", name)
|
||||||
|
self._notify("start", "Aufnahme gestartet", name, rec_id)
|
||||||
|
|
||||||
|
return {"recording_id": rec_id, "output_dir": output_dir, "output_file": output_file}
|
||||||
|
|
||||||
|
def _recording_loop(
|
||||||
|
self,
|
||||||
|
rec_id: int,
|
||||||
|
stream_url: str,
|
||||||
|
stream_type: str,
|
||||||
|
output_file: str,
|
||||||
|
output_dir: str,
|
||||||
|
extra_ffmpeg_args: str,
|
||||||
|
segment_mode: bool,
|
||||||
|
segment_duration: Optional[int],
|
||||||
|
stop_event: threading.Event,
|
||||||
|
job_name: str,
|
||||||
|
):
|
||||||
|
max_retries = self.config.get("recording", "max_retries", default=5)
|
||||||
|
retry_delay = self.config.get("recording", "retry_delay", default=5)
|
||||||
|
retry = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not stop_event.is_set():
|
||||||
|
log_fh = None
|
||||||
|
try:
|
||||||
|
cmd = self._build_ffmpeg_cmd(
|
||||||
|
stream_url, stream_type, output_file,
|
||||||
|
extra_ffmpeg_args, segment_mode, segment_duration,
|
||||||
|
)
|
||||||
|
log_path = os.path.join(output_dir, f".ffmpeg_{rec_id}.log")
|
||||||
|
log_fh = open(log_path, "a")
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=log_fh,
|
||||||
|
stderr=log_fh,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if log_fh:
|
||||||
|
log_fh.close()
|
||||||
|
self._update_recording(rec_id, status="error", error_message=str(e),
|
||||||
|
file_size=self._finalize_file_size(rec_id, segment_mode))
|
||||||
|
self._log("ERROR", f"ffmpeg konnte nicht gestartet werden: {e}", job_name)
|
||||||
|
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: {e}", rec_id, priority="high")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._processes[rec_id] = process
|
||||||
|
self._update_recording(rec_id, status="running", pid=process.pid)
|
||||||
|
proc_started = time.monotonic()
|
||||||
|
|
||||||
|
process.wait()
|
||||||
|
retcode = process.returncode
|
||||||
|
log_fh.close()
|
||||||
|
self._processes.pop(rec_id, None)
|
||||||
|
|
||||||
|
if stop_event.is_set() or retcode == 0:
|
||||||
|
status = "completed" if retcode == 0 else "stopped"
|
||||||
|
status_de = "abgeschlossen" if status == "completed" else "gestoppt"
|
||||||
|
self._update_recording(rec_id, status=status,
|
||||||
|
file_size=self._finalize_file_size(rec_id, segment_mode))
|
||||||
|
self._log("INFO", f"Aufnahme {status_de}: {job_name}", job_name)
|
||||||
|
self._notify("stop", f"Aufnahme {status_de}", job_name, rec_id)
|
||||||
|
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
|
||||||
|
return
|
||||||
|
|
||||||
|
if retcode in (-2, 130, 143, 255):
|
||||||
|
self._update_recording(rec_id, status="stopped",
|
||||||
|
file_size=self._finalize_file_size(rec_id, segment_mode))
|
||||||
|
self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
|
||||||
|
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
|
||||||
|
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
|
||||||
|
return
|
||||||
|
|
||||||
|
if time.monotonic() - proc_started >= MIN_STABLE_RUNTIME:
|
||||||
|
retry = 0
|
||||||
|
retry += 1
|
||||||
|
if max_retries > 0 and retry >= max_retries:
|
||||||
|
self._update_recording(rec_id, status="error",
|
||||||
|
error_message=f"Max retries ({max_retries}) reached, exit code {retcode}",
|
||||||
|
file_size=self._finalize_file_size(rec_id, segment_mode))
|
||||||
|
self._log("ERROR", f"Max. Versuche ({max_retries}) erreicht: {job_name}", job_name)
|
||||||
|
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: Max. Versuche erreicht", rec_id, priority="high")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._log("WARN", f"Verbindung verloren (Code: {retcode}), Reconnect in {retry_delay}s... [{job_name}]", job_name)
|
||||||
|
self._notify("error", "Stream abgerissen", f"{job_name}: Reconnect in {retry_delay}s...", rec_id)
|
||||||
|
if stop_event.wait(timeout=retry_delay):
|
||||||
|
self._update_recording(rec_id, status="stopped",
|
||||||
|
file_size=self._finalize_file_size(rec_id, segment_mode))
|
||||||
|
self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
|
||||||
|
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
|
||||||
|
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
|
||||||
|
return
|
||||||
|
|
||||||
|
sname = safe_name(job_name)
|
||||||
|
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
ext = Path(output_file).suffix.lstrip(".")
|
||||||
|
if segment_mode:
|
||||||
|
output_file = os.path.join(output_dir, f"{sname}_{ts}_seg%03d.{ext}")
|
||||||
|
else:
|
||||||
|
output_file = os.path.join(output_dir, f"{sname}_{ts}.{ext}")
|
||||||
|
self._output_files.setdefault(rec_id, []).append(output_file)
|
||||||
|
|
||||||
|
self._update_recording(rec_id, status="stopped",
|
||||||
|
file_size=self._finalize_file_size(rec_id, segment_mode))
|
||||||
|
self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
|
||||||
|
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
|
||||||
|
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
|
||||||
|
finally:
|
||||||
|
self._cleanup(rec_id)
|
||||||
|
|
||||||
|
def _build_ffmpeg_cmd(self, url, stream_type, output_file, extra_args, segment_mode, segment_duration):
|
||||||
|
url = validate_stream_url(url)
|
||||||
|
extra_tokens = validate_ffmpeg_args(extra_args or "")
|
||||||
|
|
||||||
|
cmd = ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "warning",
|
||||||
|
"-protocol_whitelist", PROTOCOL_WHITELIST]
|
||||||
|
|
||||||
|
if stream_type == "hls":
|
||||||
|
cmd += ["-i", url, "-c", "copy", "-bsf:a", "aac_adtstoasc"]
|
||||||
|
else:
|
||||||
|
cmd += ["-i", url, "-c", "copy"]
|
||||||
|
|
||||||
|
if segment_mode and segment_duration:
|
||||||
|
cmd += ["-f", "segment", "-segment_time", str(segment_duration), "-reset_timestamps", "1"]
|
||||||
|
|
||||||
|
cmd += extra_tokens
|
||||||
|
|
||||||
|
cmd.append(output_file)
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def stop_recording(self, rec_id: int) -> bool:
|
||||||
|
stop_event = self._stop_events.get(rec_id)
|
||||||
|
if stop_event:
|
||||||
|
stop_event.set()
|
||||||
|
|
||||||
|
process = self._processes.get(rec_id)
|
||||||
|
if process:
|
||||||
|
try:
|
||||||
|
process.send_signal(signal.SIGINT)
|
||||||
|
try:
|
||||||
|
process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
except (ProcessLookupError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not process and rec_id not in self._threads:
|
||||||
|
self._update_recording(rec_id, status="stopped")
|
||||||
|
self._log("INFO", f"Aufnahme gestoppt (ID: {rec_id})")
|
||||||
|
self._cleanup(rec_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def extend_recording(self, rec_id: int, extra_seconds: int) -> str:
|
||||||
|
"""Ergebnis: "ok", "not_found", "not_running" oder "unlimited"."""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
rec = session.query(Recording).filter_by(id=rec_id).first()
|
||||||
|
if not rec:
|
||||||
|
return "not_found"
|
||||||
|
if rec.status != "running":
|
||||||
|
return "not_running"
|
||||||
|
if not rec.scheduled_stop:
|
||||||
|
return "unlimited"
|
||||||
|
|
||||||
|
rec.scheduled_stop = rec.scheduled_stop + datetime.timedelta(seconds=extra_seconds)
|
||||||
|
job_name = rec.job_name
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
self._log("INFO", f"Aufnahme verlängert um {format_elapsed(extra_seconds)}: {job_name}", job_name)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
def remove_duration_limit(self, rec_id: int) -> bool:
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
rec = session.query(Recording).filter_by(id=rec_id).first()
|
||||||
|
if not rec or rec.status != "running":
|
||||||
|
return False
|
||||||
|
rec.scheduled_stop = None
|
||||||
|
session.commit()
|
||||||
|
self._log("INFO", f"Zeitlimit entfernt: {rec.job_name}", rec.job_name)
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_status(self, rec_id: int) -> Optional[dict]:
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
rec = session.query(Recording).filter_by(id=rec_id).first()
|
||||||
|
if not rec:
|
||||||
|
return None
|
||||||
|
return self._recording_to_dict(rec)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_all_active(self) -> list[dict]:
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
recs = session.query(Recording).filter(Recording.status.in_(["running", "starting"])).all()
|
||||||
|
return [self._recording_to_dict(r) for r in recs]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_all_recordings(self, limit: int = 50) -> list[dict]:
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
recs = session.query(Recording).order_by(Recording.id.desc()).limit(limit).all()
|
||||||
|
return [self._recording_to_dict(r) for r in recs]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def check_scheduled_stops(self):
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
now = utcnow()
|
||||||
|
recs = session.query(Recording).filter(
|
||||||
|
Recording.status == "running",
|
||||||
|
Recording.scheduled_stop.isnot(None),
|
||||||
|
Recording.scheduled_stop <= now,
|
||||||
|
).all()
|
||||||
|
rec_ids = [r.id for r in recs]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
for rec_id in rec_ids:
|
||||||
|
self._log("INFO", f"Geplanter Stopp erreicht (ID: {rec_id})")
|
||||||
|
self.stop_recording(rec_id)
|
||||||
|
|
||||||
|
def cleanup_stale(self):
|
||||||
|
# Nach einem Neustart kann kein ffmpeg-Kindprozess mehr laufen
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
recs = session.query(Recording).filter(Recording.status.in_(["running", "starting"])).all()
|
||||||
|
now = utcnow()
|
||||||
|
for rec in recs:
|
||||||
|
rec.status = "error"
|
||||||
|
rec.error_message = STALE_ERROR_MESSAGE
|
||||||
|
rec.stopped_at = now
|
||||||
|
try:
|
||||||
|
files = [rec.output_file] if rec.output_file else []
|
||||||
|
rec.file_size, _ = self._calc_recording_size(files, bool(rec.segment_mode))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
session.commit()
|
||||||
|
if recs:
|
||||||
|
logger.warning("%d verwaiste Aufnahme(n) als abgebrochen markiert", len(recs))
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def _recording_to_dict(self, rec: Recording) -> dict:
|
||||||
|
elapsed = 0
|
||||||
|
if rec.started_at:
|
||||||
|
end = rec.stopped_at or utcnow()
|
||||||
|
elapsed = int((end - rec.started_at).total_seconds())
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
segment_count = 0
|
||||||
|
if rec.file_size is not None:
|
||||||
|
total_size = rec.file_size
|
||||||
|
else:
|
||||||
|
files = self._output_files.get(rec.id, [rec.output_file] if rec.output_file else [])
|
||||||
|
total_size, segment_count = self._calc_recording_size(files, rec.segment_mode)
|
||||||
|
|
||||||
|
remaining = None
|
||||||
|
if rec.scheduled_stop and rec.status == "running":
|
||||||
|
diff = (rec.scheduled_stop - utcnow()).total_seconds()
|
||||||
|
remaining = max(0, int(diff))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": rec.id,
|
||||||
|
"job_id": rec.job_id,
|
||||||
|
"job_name": rec.job_name,
|
||||||
|
"stream_url": rec.stream_url,
|
||||||
|
"stream_type": rec.stream_type,
|
||||||
|
"output_dir": rec.output_dir,
|
||||||
|
"output_file": rec.output_file,
|
||||||
|
"pid": rec.pid,
|
||||||
|
"status": rec.status,
|
||||||
|
"started_at": iso_utc(rec.started_at),
|
||||||
|
"stopped_at": iso_utc(rec.stopped_at),
|
||||||
|
"scheduled_stop": iso_utc(rec.scheduled_stop),
|
||||||
|
"segment_mode": rec.segment_mode,
|
||||||
|
"is_scheduled": rec.is_scheduled,
|
||||||
|
"error_message": rec.error_message,
|
||||||
|
"elapsed": elapsed,
|
||||||
|
"elapsed_display": format_elapsed(elapsed),
|
||||||
|
"total_size": total_size,
|
||||||
|
"total_size_display": format_size(total_size),
|
||||||
|
"segment_count": segment_count if rec.segment_mode else None,
|
||||||
|
"remaining": remaining,
|
||||||
|
"remaining_display": format_elapsed(remaining) if remaining is not None else None,
|
||||||
|
"plik_url": rec.plik_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _segment_files(pattern: str) -> list[str]:
|
||||||
|
glob_pattern = globmod.escape(pattern).replace("%03d", "*")
|
||||||
|
return sorted(
|
||||||
|
f for f in globmod.glob(glob_pattern)
|
||||||
|
if os.path.isfile(f) and not os.path.basename(f).startswith(".")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_output_files(self, patterns: list[str], segment_mode: bool) -> list[str]:
|
||||||
|
files = []
|
||||||
|
for of in patterns:
|
||||||
|
if segment_mode:
|
||||||
|
files.extend(self._segment_files(of))
|
||||||
|
elif os.path.isfile(of) and os.path.getsize(of) > 0:
|
||||||
|
files.append(of)
|
||||||
|
return [f for f in files if not os.path.basename(f).startswith(".")]
|
||||||
|
|
||||||
|
def _calc_recording_size(self, output_files: list[str], segment_mode: bool) -> tuple[int, int]:
|
||||||
|
total_size = 0
|
||||||
|
segment_count = 0
|
||||||
|
for of in output_files:
|
||||||
|
if segment_mode:
|
||||||
|
for f in self._segment_files(of):
|
||||||
|
total_size += os.path.getsize(f)
|
||||||
|
segment_count += 1
|
||||||
|
else:
|
||||||
|
if os.path.isfile(of):
|
||||||
|
total_size += os.path.getsize(of)
|
||||||
|
segment_count += 1
|
||||||
|
return total_size, segment_count
|
||||||
|
|
||||||
|
def _finalize_file_size(self, rec_id: int, segment_mode: bool) -> int:
|
||||||
|
files = self._output_files.get(rec_id, [])
|
||||||
|
if not files:
|
||||||
|
return 0
|
||||||
|
total_size, _ = self._calc_recording_size(files, segment_mode)
|
||||||
|
return total_size
|
||||||
|
|
||||||
|
def _update_recording(self, rec_id: int, **kwargs):
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
rec = session.query(Recording).filter_by(id=rec_id).first()
|
||||||
|
if rec:
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
setattr(rec, k, v)
|
||||||
|
if kwargs.get("status") in ("stopped", "completed", "error"):
|
||||||
|
rec.stopped_at = utcnow()
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def _handle_post_recording(self, rec_id: int, output_dir: str, output_file: str,
|
||||||
|
job_name: str, segment_mode: bool):
|
||||||
|
opts = self._rec_options.get(rec_id, {})
|
||||||
|
if opts.get("plik_enabled"):
|
||||||
|
patterns = list(self._output_files.get(rec_id, [output_file]))
|
||||||
|
files = self._resolve_output_files(patterns, segment_mode)
|
||||||
|
self._get_plik().upload_recording(
|
||||||
|
files=files,
|
||||||
|
job_name=job_name,
|
||||||
|
segment_mode=segment_mode,
|
||||||
|
delete_after=opts.get("delete_after_upload", False),
|
||||||
|
ntfy_enabled=opts.get("ntfy_enabled", False),
|
||||||
|
save_url_fn=lambda url: self._update_recording(rec_id, plik_url=url),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cleanup(self, rec_id: int):
|
||||||
|
self._processes.pop(rec_id, None)
|
||||||
|
self._stop_events.pop(rec_id, None)
|
||||||
|
self._threads.pop(rec_id, None)
|
||||||
|
self._rec_options.pop(rec_id, None)
|
||||||
|
self._output_files.pop(rec_id, None)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==9.1.1
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi==0.143.0
|
||||||
|
uvicorn[standard]==0.54.0
|
||||||
|
sqlalchemy==2.0.54
|
||||||
|
pydantic==2.14.0
|
||||||
|
apscheduler==3.11.3
|
||||||
|
pyyaml==6.0.3
|
||||||
|
httpx==0.28.1
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from models import Job, Recording, get_session, prune_logs, utcnow
|
||||||
|
|
||||||
|
logger = logging.getLogger("scheduler")
|
||||||
|
|
||||||
|
DAY_MAP = {
|
||||||
|
"mo": 1, "mon": 1, "montag": 1,
|
||||||
|
"di": 2, "tue": 2, "dienstag": 2,
|
||||||
|
"mi": 3, "wed": 3, "mittwoch": 3,
|
||||||
|
"do": 4, "thu": 4, "donnerstag": 4,
|
||||||
|
"fr": 5, "fri": 5, "freitag": 5,
|
||||||
|
"sa": 6, "sat": 6, "samstag": 6,
|
||||||
|
"so": 7, "sun": 7, "sonntag": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
_TIME_RE = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _day_to_num(day: str) -> int:
|
||||||
|
return DAY_MAP.get(day.lower().strip(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_day(today_num: int, schedule: str) -> bool:
|
||||||
|
if schedule.strip() == "*":
|
||||||
|
return True
|
||||||
|
for entry in schedule.split(","):
|
||||||
|
entry = entry.strip()
|
||||||
|
if "-" in entry:
|
||||||
|
parts = entry.split("-", 1)
|
||||||
|
start = _day_to_num(parts[0])
|
||||||
|
end = _day_to_num(parts[1])
|
||||||
|
if not start or not end:
|
||||||
|
continue
|
||||||
|
if start <= end:
|
||||||
|
if start <= today_num <= end:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
if today_num >= start or today_num <= end:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
if _day_to_num(entry) == today_num:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_time(t) -> bool:
|
||||||
|
return isinstance(t, str) and bool(_TIME_RE.match(t))
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_date(d) -> bool:
|
||||||
|
if not isinstance(d, str) or not re.match(r"^\d{4}-\d{2}-\d{2}$", d):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
datetime.date.fromisoformat(d)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_days(days) -> bool:
|
||||||
|
if not isinstance(days, str):
|
||||||
|
return False
|
||||||
|
days = days.strip()
|
||||||
|
if days == "*":
|
||||||
|
return True
|
||||||
|
if not days:
|
||||||
|
return False
|
||||||
|
for entry in days.split(","):
|
||||||
|
entry = entry.strip()
|
||||||
|
parts = entry.split("-", 1) if "-" in entry else [entry]
|
||||||
|
if not all(_day_to_num(p) for p in parts):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def validate_schedule(job: dict):
|
||||||
|
"""Prüft Zeitplanfelder. Wirft ValueError mit deutscher Meldung."""
|
||||||
|
start = job.get("schedule_start") or ""
|
||||||
|
stop = job.get("schedule_stop") or ""
|
||||||
|
date = job.get("schedule_date") or ""
|
||||||
|
end_date = job.get("schedule_end_date") or ""
|
||||||
|
days = job.get("schedule_days")
|
||||||
|
days = "*" if days is None else days
|
||||||
|
|
||||||
|
if start and not is_valid_time(start):
|
||||||
|
raise ValueError("Startzeit muss im Format HH:MM angegeben werden")
|
||||||
|
if stop and not is_valid_time(stop):
|
||||||
|
raise ValueError("Stoppzeit muss im Format HH:MM angegeben werden")
|
||||||
|
if date and not is_valid_date(date):
|
||||||
|
raise ValueError("Datum muss im Format JJJJ-MM-TT angegeben werden")
|
||||||
|
if end_date and not is_valid_date(end_date):
|
||||||
|
raise ValueError("Enddatum muss im Format JJJJ-MM-TT angegeben werden")
|
||||||
|
if not is_valid_days(days):
|
||||||
|
raise ValueError("Ungültige Wochentage (erlaubt: * oder z.B. mo-fr,sa)")
|
||||||
|
if job.get("schedule_enabled") and not start:
|
||||||
|
raise ValueError("Für einen aktiven Zeitplan ist eine Startzeit erforderlich")
|
||||||
|
if job.get("schedule_once") and not date:
|
||||||
|
raise ValueError("Für eine einmalige Aufnahme ist ein Datum erforderlich")
|
||||||
|
if end_date:
|
||||||
|
if not date:
|
||||||
|
raise ValueError("Enddatum erfordert ein Startdatum")
|
||||||
|
if end_date < date:
|
||||||
|
raise ValueError("Enddatum darf nicht vor dem Startdatum liegen")
|
||||||
|
|
||||||
|
|
||||||
|
def _time_to_minutes(t: str) -> int:
|
||||||
|
if not is_valid_time(t):
|
||||||
|
raise ValueError(f"Ungültige Uhrzeit: {t!r}")
|
||||||
|
parts = t.split(":")
|
||||||
|
return int(parts[0]) * 60 + int(parts[1])
|
||||||
|
|
||||||
|
|
||||||
|
def _in_time_window(now_time: str, start: str, stop: str) -> bool:
|
||||||
|
now_min = _time_to_minutes(now_time)
|
||||||
|
start_min = _time_to_minutes(start)
|
||||||
|
stop_min = _time_to_minutes(stop)
|
||||||
|
if start_min <= stop_min:
|
||||||
|
return start_min <= now_min < stop_min
|
||||||
|
else:
|
||||||
|
return now_min >= start_min or now_min < stop_min
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_buffers(job: dict) -> dict:
|
||||||
|
eff = dict(job)
|
||||||
|
pre = job.get("pre_buffer", 0) or 0
|
||||||
|
post = job.get("post_buffer", 0) or 0
|
||||||
|
if pre > 0 and is_valid_time(eff.get("schedule_start")):
|
||||||
|
m = _time_to_minutes(eff["schedule_start"]) - pre
|
||||||
|
m %= 1440
|
||||||
|
eff["schedule_start"] = f"{m // 60:02d}:{m % 60:02d}"
|
||||||
|
if post > 0 and is_valid_time(eff.get("schedule_stop")):
|
||||||
|
m = _time_to_minutes(eff["schedule_stop"]) + post
|
||||||
|
m %= 1440
|
||||||
|
eff["schedule_stop"] = f"{m // 60:02d}:{m % 60:02d}"
|
||||||
|
return eff
|
||||||
|
|
||||||
|
|
||||||
|
def _is_multi_day(job: dict) -> bool:
|
||||||
|
return bool(job.get("schedule_once") and job.get("schedule_date") and job.get("schedule_end_date"))
|
||||||
|
|
||||||
|
|
||||||
|
def compute_duration(job: dict, apply_buffers: bool) -> int | None:
|
||||||
|
"""Aufnahmedauer in Sekunden aus max_duration bzw. Zeitplan. Wirft ValueError bei ungültigen Daten."""
|
||||||
|
duration = job.get("max_duration")
|
||||||
|
if duration:
|
||||||
|
return int(duration)
|
||||||
|
start = job.get("schedule_start") or ""
|
||||||
|
stop = job.get("schedule_stop") or ""
|
||||||
|
if not start or not stop:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pre = (job.get("pre_buffer") or 0) if apply_buffers else 0
|
||||||
|
post = (job.get("post_buffer") or 0) if apply_buffers else 0
|
||||||
|
|
||||||
|
if _is_multi_day(job):
|
||||||
|
try:
|
||||||
|
start_dt = datetime.datetime.strptime(f"{job['schedule_date']} {start}", "%Y-%m-%d %H:%M")
|
||||||
|
end_dt = datetime.datetime.strptime(f"{job['schedule_end_date']} {stop}", "%Y-%m-%d %H:%M")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("Ungültiges Datum oder Uhrzeit im Zeitplan")
|
||||||
|
start_dt -= datetime.timedelta(minutes=pre)
|
||||||
|
end_dt += datetime.timedelta(minutes=post)
|
||||||
|
return max(0, int((end_dt - start_dt).total_seconds()))
|
||||||
|
|
||||||
|
eff = _apply_buffers({**job, "pre_buffer": pre, "post_buffer": post})
|
||||||
|
start_min = _time_to_minutes(eff["schedule_start"])
|
||||||
|
stop_min = _time_to_minutes(eff["schedule_stop"])
|
||||||
|
if stop_min > start_min:
|
||||||
|
return (stop_min - start_min) * 60
|
||||||
|
return (1440 - start_min + stop_min) * 60
|
||||||
|
|
||||||
|
|
||||||
|
class StreamScheduler:
|
||||||
|
def __init__(self, recorder):
|
||||||
|
self.recorder = recorder
|
||||||
|
self._scheduler = BackgroundScheduler()
|
||||||
|
self._started_keys: set[str] = set()
|
||||||
|
self._last_errors: dict[int, str] = {}
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._scheduler.add_job(self._tick, "interval", seconds=30, id="scheduler_tick",
|
||||||
|
replace_existing=True, max_instances=1)
|
||||||
|
self._scheduler.add_job(self.recorder.check_scheduled_stops, "interval", seconds=10,
|
||||||
|
id="check_stops", replace_existing=True, max_instances=1)
|
||||||
|
self._scheduler.add_job(self.prune_logs, "interval", hours=24,
|
||||||
|
id="prune_logs", replace_existing=True, max_instances=1)
|
||||||
|
self._scheduler.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._scheduler.shutdown(wait=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def prune_logs():
|
||||||
|
try:
|
||||||
|
days = int(os.environ.get("LOG_RETENTION_DAYS", "90"))
|
||||||
|
except ValueError:
|
||||||
|
days = 90
|
||||||
|
return prune_logs(days)
|
||||||
|
|
||||||
|
def _original_occ_key(self, job: dict, now: datetime.datetime | None = None) -> str:
|
||||||
|
now = now or datetime.datetime.now()
|
||||||
|
today_date = now.strftime("%Y-%m-%d")
|
||||||
|
date = today_date if not job["schedule_once"] else job["schedule_date"]
|
||||||
|
return f"{job['id']}|{job['schedule_once']}|{date}|{job['schedule_start']}"
|
||||||
|
|
||||||
|
def _schedule_state(self, job: dict, now: datetime.datetime | None = None) -> dict:
|
||||||
|
now = now or datetime.datetime.now()
|
||||||
|
today_num = now.isoweekday()
|
||||||
|
today_date = now.strftime("%Y-%m-%d")
|
||||||
|
now_min = now.hour * 60 + now.minute
|
||||||
|
|
||||||
|
occ_key = (
|
||||||
|
f"{job['id']}|{job['schedule_once']}|"
|
||||||
|
f"{today_date if not job['schedule_once'] else job['schedule_date']}|"
|
||||||
|
f"{job['schedule_start']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
multi_day = _is_multi_day(job)
|
||||||
|
state = {
|
||||||
|
"occ_key": occ_key,
|
||||||
|
"in_window": False,
|
||||||
|
"multi_day": multi_day,
|
||||||
|
"is_cross_midnight": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
start = job.get("schedule_start") or ""
|
||||||
|
stop = job.get("schedule_stop") or ""
|
||||||
|
if not is_valid_time(start):
|
||||||
|
return state
|
||||||
|
if not is_valid_time(stop):
|
||||||
|
stop = ""
|
||||||
|
days = job.get("schedule_days") or "*"
|
||||||
|
|
||||||
|
is_today = False
|
||||||
|
if job["schedule_once"]:
|
||||||
|
if job["schedule_date"] == today_date:
|
||||||
|
is_today = True
|
||||||
|
else:
|
||||||
|
if _matches_day(today_num, days):
|
||||||
|
is_today = True
|
||||||
|
|
||||||
|
start_min = _time_to_minutes(start)
|
||||||
|
in_window = False
|
||||||
|
is_cross_midnight = False
|
||||||
|
if is_today:
|
||||||
|
if multi_day or not stop:
|
||||||
|
in_window = now_min >= start_min
|
||||||
|
else:
|
||||||
|
stop_min = _time_to_minutes(stop)
|
||||||
|
if stop_min <= start_min:
|
||||||
|
is_cross_midnight = True
|
||||||
|
in_window = now_min >= start_min
|
||||||
|
else:
|
||||||
|
in_window = start_min <= now_min < stop_min
|
||||||
|
elif stop:
|
||||||
|
stop_min = _time_to_minutes(stop)
|
||||||
|
if stop_min <= start_min and stop_min > 0 and now_min < stop_min:
|
||||||
|
yesterday = now - datetime.timedelta(days=1)
|
||||||
|
if job["schedule_once"]:
|
||||||
|
was_yesterday = job["schedule_date"] == yesterday.strftime("%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
was_yesterday = _matches_day(yesterday.isoweekday(), days)
|
||||||
|
if was_yesterday:
|
||||||
|
is_cross_midnight = True
|
||||||
|
in_window = True
|
||||||
|
|
||||||
|
state["in_window"] = in_window
|
||||||
|
state["is_cross_midnight"] = is_cross_midnight
|
||||||
|
return state
|
||||||
|
|
||||||
|
def suppress_current_occurrence(self, job_id: int) -> bool:
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
j = session.query(Job).filter_by(id=job_id).first()
|
||||||
|
if not j or not j.schedule_enabled or not j.schedule_start:
|
||||||
|
return False
|
||||||
|
job = {
|
||||||
|
"id": j.id,
|
||||||
|
"schedule_once": j.schedule_once,
|
||||||
|
"schedule_date": j.schedule_date,
|
||||||
|
"schedule_days": j.schedule_days,
|
||||||
|
"schedule_start": j.schedule_start,
|
||||||
|
"schedule_stop": j.schedule_stop,
|
||||||
|
"schedule_end_date": j.schedule_end_date,
|
||||||
|
"pre_buffer": j.pre_buffer or 0,
|
||||||
|
"post_buffer": j.post_buffer or 0,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
occ_key = self._original_occ_key(job)
|
||||||
|
state = self._schedule_state(_apply_buffers(job))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Zeitplan von Job %s ungültig: %s", job_id, e)
|
||||||
|
return False
|
||||||
|
if not state["in_window"]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._started_keys.add(occ_key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _occurrence_start_utc(self, job: dict, now: datetime.datetime) -> datetime.datetime | None:
|
||||||
|
date_text = job["schedule_date"] if job["schedule_once"] else now.strftime("%Y-%m-%d")
|
||||||
|
try:
|
||||||
|
local_start = datetime.datetime.strptime(
|
||||||
|
f"{date_text} {job['schedule_start']}",
|
||||||
|
"%Y-%m-%d %H:%M",
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return local_start.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
def _has_recording_for_occurrence(self, job: dict, now: datetime.datetime) -> bool:
|
||||||
|
occurrence_start = self._occurrence_start_utc(job, now)
|
||||||
|
if occurrence_start is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
return session.query(Recording).filter(
|
||||||
|
Recording.job_id == job["id"],
|
||||||
|
or_(
|
||||||
|
Recording.started_at >= occurrence_start,
|
||||||
|
Recording.stopped_at >= occurrence_start,
|
||||||
|
),
|
||||||
|
).first() is not None
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def _is_job_recording(self, job_id: int) -> bool:
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
return session.query(Recording).filter(
|
||||||
|
Recording.job_id == job_id,
|
||||||
|
Recording.status.in_(["running", "starting"]),
|
||||||
|
).first() is not None
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def _get_active_scheduled_recording_for_job(self, job_id: int, *, respect_stop: bool = False):
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
rec = session.query(Recording).filter(
|
||||||
|
Recording.job_id == job_id,
|
||||||
|
Recording.status.in_(["running", "starting"]),
|
||||||
|
Recording.is_scheduled == True,
|
||||||
|
).first()
|
||||||
|
if not rec:
|
||||||
|
return None
|
||||||
|
if respect_stop and rec.scheduled_stop and rec.scheduled_stop > utcnow():
|
||||||
|
return None
|
||||||
|
return rec.id
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def _prune_started_keys(self, now: datetime.datetime):
|
||||||
|
cutoff = (now - datetime.timedelta(days=2)).strftime("%Y-%m-%d")
|
||||||
|
for key in list(self._started_keys):
|
||||||
|
parts = key.split("|")
|
||||||
|
date = parts[2] if len(parts) >= 4 else ""
|
||||||
|
if not is_valid_date(date) or date < cutoff:
|
||||||
|
self._started_keys.discard(key)
|
||||||
|
|
||||||
|
def _tick(self):
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
self._prune_started_keys(now)
|
||||||
|
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
jobs = session.query(Job).filter_by(schedule_enabled=True).all()
|
||||||
|
job_list = []
|
||||||
|
for j in jobs:
|
||||||
|
job_list.append({
|
||||||
|
"id": j.id, "name": j.name, "stream_url": j.stream_url,
|
||||||
|
"stream_type": j.stream_type, "output_format": j.output_format,
|
||||||
|
"max_duration": j.max_duration, "extra_ffmpeg_args": j.extra_ffmpeg_args,
|
||||||
|
"segment_duration": j.segment_duration,
|
||||||
|
"schedule_once": j.schedule_once, "schedule_date": j.schedule_date,
|
||||||
|
"schedule_days": j.schedule_days, "schedule_start": j.schedule_start,
|
||||||
|
"schedule_stop": j.schedule_stop, "schedule_end_date": j.schedule_end_date,
|
||||||
|
"pre_buffer": j.pre_buffer or 0, "post_buffer": j.post_buffer or 0,
|
||||||
|
"description": j.description,
|
||||||
|
"ntfy_enabled": j.ntfy_enabled,
|
||||||
|
"plik_enabled": j.plik_enabled,
|
||||||
|
"delete_after_upload": j.delete_after_upload,
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
active_ids = set()
|
||||||
|
for job in job_list:
|
||||||
|
active_ids.add(job["id"])
|
||||||
|
try:
|
||||||
|
self._process_job(job, now)
|
||||||
|
self._last_errors.pop(job["id"], None)
|
||||||
|
except Exception as e:
|
||||||
|
err = f"{type(e).__name__}: {e}"
|
||||||
|
if self._last_errors.get(job["id"]) != err:
|
||||||
|
self._last_errors[job["id"]] = err
|
||||||
|
logger.error("Zeitplan-Fehler bei Job %s (%s): %s", job["id"], job.get("name"), err)
|
||||||
|
try:
|
||||||
|
self.recorder._log("ERROR", f"Zeitplan-Fehler: {e}", job.get("name"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for job_id in list(self._last_errors):
|
||||||
|
if job_id not in active_ids:
|
||||||
|
self._last_errors.pop(job_id, None)
|
||||||
|
|
||||||
|
def _process_job(self, job: dict, now: datetime.datetime):
|
||||||
|
if not job["schedule_start"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
occ_key = self._original_occ_key(job, now)
|
||||||
|
eff_job = _apply_buffers(job)
|
||||||
|
state = self._schedule_state(eff_job, now)
|
||||||
|
|
||||||
|
if state["in_window"]:
|
||||||
|
if (
|
||||||
|
occ_key not in self._started_keys
|
||||||
|
and not self._is_job_recording(job["id"])
|
||||||
|
and not self._has_recording_for_occurrence(job, now)
|
||||||
|
):
|
||||||
|
duration = compute_duration(job, apply_buffers=True)
|
||||||
|
# Erst merken, damit ein Startfehler nicht alle 30s erneut versucht wird
|
||||||
|
self._started_keys.add(occ_key)
|
||||||
|
self.recorder.start_recording(
|
||||||
|
job_id=job["id"],
|
||||||
|
name=job["name"],
|
||||||
|
stream_url=job["stream_url"],
|
||||||
|
stream_type=job["stream_type"],
|
||||||
|
output_format=job["output_format"] or "",
|
||||||
|
max_duration=duration,
|
||||||
|
extra_ffmpeg_args=job["extra_ffmpeg_args"] or "",
|
||||||
|
segment_duration=job["segment_duration"],
|
||||||
|
is_scheduled=True,
|
||||||
|
description=job.get("description", ""),
|
||||||
|
ntfy_enabled=job.get("ntfy_enabled", False),
|
||||||
|
plik_enabled=job.get("plik_enabled", False),
|
||||||
|
delete_after_upload=job.get("delete_after_upload", False),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if (
|
||||||
|
not state["multi_day"]
|
||||||
|
and not state["is_cross_midnight"]
|
||||||
|
and eff_job["schedule_stop"]
|
||||||
|
and self._is_job_recording(job["id"])
|
||||||
|
):
|
||||||
|
rec_id = self._get_active_scheduled_recording_for_job(
|
||||||
|
job["id"], respect_stop=True,
|
||||||
|
)
|
||||||
|
if rec_id:
|
||||||
|
self.recorder.stop_recording(rec_id)
|
||||||
|
self._started_keys.discard(occ_key)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_TMP = tempfile.mkdtemp(prefix="stream-recorder-tests-")
|
||||||
|
os.environ["DATA_DIR"] = os.path.join(_TMP, "data")
|
||||||
|
os.environ["STATIC_DIR"] = os.path.join(_TMP, "no-static")
|
||||||
|
os.environ["RECORDING_ROOTS"] = os.path.join(_TMP, "recordings")
|
||||||
|
os.environ.pop("ENABLE_API_DOCS", None)
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
|
import app as app_module # noqa: E402
|
||||||
|
from config import hash_password # noqa: E402
|
||||||
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
from models import get_session, Job, Station, Recording, LogEntry # noqa: E402
|
||||||
|
|
||||||
|
TEST_PASSWORD = "geheim-12345"
|
||||||
|
# Schneller Hash für Tests
|
||||||
|
TEST_HASH = hash_password(TEST_PASSWORD, iterations=1000)
|
||||||
|
|
||||||
|
|
||||||
|
def basic(user, password):
|
||||||
|
return "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_mod():
|
||||||
|
return app_module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app_mod):
|
||||||
|
cfg = app_mod.config
|
||||||
|
with cfg.lock:
|
||||||
|
cfg.data["auth"] = {"enabled": False, "username": "admin", "password_hash": TEST_HASH}
|
||||||
|
cfg.data["ntfy"] = {"url": "https://ntfy.example/topic", "token": "tk_secret", "events": "error"}
|
||||||
|
cfg.data["plik"] = {"url": "https://plik.example", "api_key": "plik_secret", "ttl": "30d"}
|
||||||
|
cfg.data["recording"]["download_path"] = os.environ["RECORDING_ROOTS"]
|
||||||
|
app_mod.clear_auth_cache()
|
||||||
|
app_mod.reset_auth_failures()
|
||||||
|
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
for model in (Job, Station, Recording, LogEntry):
|
||||||
|
session.query(model).delete()
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
return TestClient(app_mod.app)
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
from conftest import TEST_PASSWORD, basic
|
||||||
|
from models import get_session, Job, Recording, LogEntry, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
def _job(**kw):
|
||||||
|
data = {"name": "Testjob", "stream_url": "https://example.com/stream.mp3"}
|
||||||
|
data.update(kw)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# --- Config ---
|
||||||
|
|
||||||
|
def test_config_get_never_leaks_secrets(client):
|
||||||
|
r = client.get("/api/config")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
text = r.text
|
||||||
|
for secret in ("tk_secret", "plik_secret", "pbkdf2", TEST_PASSWORD):
|
||||||
|
assert secret not in text
|
||||||
|
assert body["auth"]["password_set"] is True
|
||||||
|
assert body["ntfy"]["token_set"] is True
|
||||||
|
assert body["plik"]["api_key_set"] is True
|
||||||
|
assert "password" not in body["auth"] and "password_hash" not in body["auth"]
|
||||||
|
assert "token" not in body["ntfy"] and "api_key" not in body["plik"]
|
||||||
|
assert set(body) == {"recording", "server", "auth", "ntfy", "plik", "schedule", "logging"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_put_forbidden_download_path(client):
|
||||||
|
for path in ("/etc", "relative/path", os.environ["RECORDING_ROOTS"] + "/../escape"):
|
||||||
|
r = client.put("/api/config", json={"data": {"recording": {"download_path": path}}})
|
||||||
|
assert r.status_code == 400, path
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_put_allowed_download_path(client, app_mod):
|
||||||
|
sub = os.path.join(os.environ["RECORDING_ROOTS"], "sub")
|
||||||
|
r = client.put("/api/config", json={"data": {"recording": {"download_path": sub}}})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert app_mod.config.get("recording", "download_path") == os.path.realpath(sub)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_put_rejects_server_and_unknown(client):
|
||||||
|
assert client.put("/api/config", json={"data": {"server": {"port": 1}}}).status_code == 422
|
||||||
|
assert client.put("/api/config", json={"data": {"auth": {"foo": 1}}}).status_code == 422
|
||||||
|
assert client.put("/api/config", json={"data": {"recording": {"max_retries": 5000}}}).status_code == 422
|
||||||
|
assert client.put("/api/config", json={"data": {"logging": {"level": "TRACE"}}}).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_put_semantic_validation(client):
|
||||||
|
assert client.put("/api/config", json={"data": {"ntfy": {"events": "start,foo"}}}).status_code == 400
|
||||||
|
assert client.put("/api/config", json={"data": {"ntfy": {"url": "file:///x"}}}).status_code == 400
|
||||||
|
assert client.put("/api/config", json={"data": {"plik": {"ttl": "30x"}}}).status_code == 400
|
||||||
|
assert client.put("/api/config", json={"data": {"plik": {"ttl": "12h"}}}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_secrets_semantics(client, app_mod):
|
||||||
|
cfg = app_mod.config
|
||||||
|
# fehlt -> unverändert
|
||||||
|
assert client.put("/api/config", json={"data": {"ntfy": {"url": "https://n.example/t"}}}).status_code == 200
|
||||||
|
assert cfg.get("ntfy", "token") == "tk_secret"
|
||||||
|
# leer -> löschen
|
||||||
|
assert client.put("/api/config", json={"data": {"ntfy": {"token": ""}, "plik": {"api_key": ""}}}).status_code == 200
|
||||||
|
assert cfg.get("ntfy", "token") == "" and cfg.get("plik", "api_key") == ""
|
||||||
|
# Passwort leer -> 400, zu kurz -> 400
|
||||||
|
assert client.put("/api/config", json={"data": {"auth": {"password": ""}}}).status_code == 400
|
||||||
|
assert client.put("/api/config", json={"data": {"auth": {"password": "kurz"}}}).status_code == 400
|
||||||
|
old_hash = cfg.get("auth", "password_hash")
|
||||||
|
assert client.put("/api/config", json={"data": {"auth": {"password": "neues-passwort"}}}).status_code == 200
|
||||||
|
assert cfg.get("auth", "password_hash") != old_hash
|
||||||
|
assert "neues-passwort" not in open(cfg.config_path, encoding="utf-8").read()
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_auth_without_password(client, app_mod):
|
||||||
|
app_mod.config.data["auth"]["password_hash"] = ""
|
||||||
|
r = client.put("/api/config", json={"data": {"auth": {"enabled": True}}})
|
||||||
|
assert r.status_code == 400
|
||||||
|
r = client.put("/api/config", json={"data": {"auth": {"enabled": True, "password": "langes-passwort"}}})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert client.get("/api/config").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# --- Auth ---
|
||||||
|
|
||||||
|
def test_auth_middleware(client, app_mod):
|
||||||
|
app_mod.config.data["auth"]["enabled"] = True
|
||||||
|
assert client.get("/api/health").status_code == 200
|
||||||
|
assert client.get("/api/health/x").status_code == 401
|
||||||
|
r = client.get("/api/jobs")
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert "Basic" in r.headers["www-authenticate"]
|
||||||
|
assert client.get("/api/jobs", headers={"Authorization": basic("admin", TEST_PASSWORD)}).status_code == 200
|
||||||
|
# zweiter Aufruf über Cache
|
||||||
|
assert client.get("/api/jobs", headers={"Authorization": basic("admin", TEST_PASSWORD)}).status_code == 200
|
||||||
|
assert client.get("/api/jobs", headers={"Authorization": "Basic !!!"}).status_code == 401
|
||||||
|
assert client.get("/api/jobs", headers={"Authorization": basic("root", TEST_PASSWORD)}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_bruteforce_429(client, app_mod):
|
||||||
|
app_mod.config.data["auth"]["enabled"] = True
|
||||||
|
for _ in range(10):
|
||||||
|
assert client.get("/api/jobs", headers={"Authorization": basic("admin", "falsch")}).status_code == 401
|
||||||
|
assert client.get("/api/jobs", headers={"Authorization": basic("admin", "falsch")}).status_code == 429
|
||||||
|
assert client.get("/api/jobs", headers={"Authorization": basic("admin", TEST_PASSWORD)}).status_code == 429
|
||||||
|
assert client.get("/api/health").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# --- CSRF / Header ---
|
||||||
|
|
||||||
|
def test_csrf(client):
|
||||||
|
body = _job()
|
||||||
|
assert client.post("/api/jobs", json=body, headers={"Sec-Fetch-Site": "cross-site"}).status_code == 403
|
||||||
|
assert client.post("/api/jobs", json=body, headers={"Origin": "https://evil.example"}).status_code == 403
|
||||||
|
assert client.post("/api/jobs", json=body, headers={"Origin": "null"}).status_code == 403
|
||||||
|
r = client.post("/api/jobs", json=body, headers={"Origin": "http://testserver", "Sec-Fetch-Site": "same-origin"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert client.post("/api/jobs", json=body).status_code == 200
|
||||||
|
# GET bleibt unberührt
|
||||||
|
assert client.get("/api/jobs", headers={"Sec-Fetch-Site": "cross-site"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_headers_and_docs(client):
|
||||||
|
r = client.get("/api/health")
|
||||||
|
assert r.headers["x-content-type-options"] == "nosniff"
|
||||||
|
assert r.headers["x-frame-options"] == "DENY"
|
||||||
|
assert r.headers["referrer-policy"] == "no-referrer"
|
||||||
|
assert "frame-ancestors 'none'" in r.headers["content-security-policy"]
|
||||||
|
for path in ("/docs", "/redoc", "/openapi.json"):
|
||||||
|
assert client.get(path).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# --- Jobs ---
|
||||||
|
|
||||||
|
def test_job_validation(client):
|
||||||
|
assert client.post("/api/jobs", json=_job(stream_url="file:///etc/passwd")).status_code == 400
|
||||||
|
assert client.post("/api/jobs", json=_job(stream_url="concat:a|b")).status_code == 400
|
||||||
|
r = client.post("/api/jobs", json=_job(extra_ffmpeg_args="-c:a copy /app/app.py"))
|
||||||
|
assert r.status_code == 400 and "/app/app.py" in r.json()["detail"]
|
||||||
|
assert client.post("/api/jobs", json=_job(schedule_enabled=True)).status_code == 400
|
||||||
|
assert client.post("/api/jobs", json=_job(schedule_start="24:00")).status_code == 400
|
||||||
|
assert client.post("/api/jobs", json=_job(station_id=9999)).status_code == 400
|
||||||
|
assert client.post("/api/jobs", json=_job(stream_type="evil")).status_code == 422
|
||||||
|
assert client.post("/api/jobs", json=_job(name=" ")).status_code == 422
|
||||||
|
assert client.post("/api/jobs", json=_job(segment_duration=5)).status_code == 422
|
||||||
|
assert client.post("/api/jobs", json=_job(pre_buffer=500)).status_code == 422
|
||||||
|
r = client.post("/api/jobs", json=_job(segment_duration=0, schedule_enabled=True, schedule_start="10:00",
|
||||||
|
schedule_days="mo-fr", extra_ffmpeg_args="-c:a libmp3lame -b:a 128k"))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
job = client.get(f"/api/jobs/{r.json()['id']}").json()
|
||||||
|
assert job["segment_duration"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_timestamps_are_utc_aware(client):
|
||||||
|
job_id = client.post("/api/jobs", json=_job()).json()["id"]
|
||||||
|
station_id = client.post("/api/stations", json={"name": "S", "stream_url": "https://e.example/s"}).json()["id"]
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
session.add(Recording(job_id=None, job_name="x", stream_url="https://e.example", status="completed",
|
||||||
|
stopped_at=utcnow()))
|
||||||
|
session.add(LogEntry(level="INFO", message="m"))
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
stamps = [
|
||||||
|
client.get(f"/api/jobs/{job_id}").json()["created_at"],
|
||||||
|
client.get("/api/jobs").json()[0]["created_at"],
|
||||||
|
client.get(f"/api/stations/{station_id}").json()["created_at"],
|
||||||
|
client.get("/api/recordings").json()[0]["started_at"],
|
||||||
|
client.get("/api/recordings").json()[0]["stopped_at"],
|
||||||
|
client.get("/api/logs").json()[0]["timestamp"],
|
||||||
|
]
|
||||||
|
for s in stamps:
|
||||||
|
assert s.endswith("+00:00"), s
|
||||||
|
assert abs(datetime.datetime.fromisoformat(s) - datetime.datetime.now(datetime.timezone.utc)).total_seconds() < 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_station_unlinks_jobs(client):
|
||||||
|
sid = client.post("/api/stations", json={"name": "S", "stream_url": "https://e.example/s"}).json()["id"]
|
||||||
|
jid = client.post("/api/jobs", json=_job(station_id=sid)).json()["id"]
|
||||||
|
assert client.delete(f"/api/stations/{sid}").status_code == 200
|
||||||
|
assert client.get(f"/api/jobs/{jid}").json()["station_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def _add_recording(**kw):
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
rec = Recording(job_name="x", stream_url="https://e.example", **kw)
|
||||||
|
session.add(rec)
|
||||||
|
session.commit()
|
||||||
|
return rec.id
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_and_start_job_while_recording(client, app_mod, monkeypatch):
|
||||||
|
jid = client.post("/api/jobs", json=_job()).json()["id"]
|
||||||
|
_add_recording(job_id=jid, status="running")
|
||||||
|
assert client.delete(f"/api/jobs/{jid}").status_code == 400
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: called.append(kw) or {})
|
||||||
|
assert client.post(f"/api/jobs/{jid}/record").status_code == 409
|
||||||
|
assert not called
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_job_legacy_bad_data(client, app_mod, monkeypatch):
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
job = Job(name="alt", stream_url="https://e.example/s", schedule_start="10:00", schedule_stop="xx")
|
||||||
|
session.add(job)
|
||||||
|
session.commit()
|
||||||
|
jid = job.id
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: {"recording_id": 1})
|
||||||
|
assert client.post(f"/api/jobs/{jid}/record").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_job_passes_duration(client, app_mod, monkeypatch):
|
||||||
|
jid = client.post("/api/jobs", json=_job(schedule_start="10:00", schedule_stop="11:30",
|
||||||
|
pre_buffer=5)).json()["id"]
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: calls.append(kw) or {"recording_id": 1})
|
||||||
|
assert client.post(f"/api/jobs/{jid}/record").status_code == 200
|
||||||
|
assert calls[0]["max_duration"] == 5400
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_record_validation(client, app_mod, monkeypatch):
|
||||||
|
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: {"recording_id": 1})
|
||||||
|
assert client.post("/api/quick-record", json={"url": "file:///etc/passwd"}).status_code == 400
|
||||||
|
assert client.post("/api/quick-record", json={"url": "https://e.example/s", "max_duration": 0}).status_code == 422
|
||||||
|
assert client.post("/api/quick-record", json={"url": "https://e.example/s"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# --- Recordings ---
|
||||||
|
|
||||||
|
def test_extend(client):
|
||||||
|
unlimited = _add_recording(status="running")
|
||||||
|
r = client.post(f"/api/recordings/{unlimited}/extend?minutes=10")
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["detail"] == "Aufnahme ist bereits unbegrenzt"
|
||||||
|
|
||||||
|
stop = utcnow() + datetime.timedelta(minutes=5)
|
||||||
|
limited = _add_recording(status="running", scheduled_stop=stop)
|
||||||
|
assert client.post(f"/api/recordings/{limited}/extend?minutes=10").status_code == 200
|
||||||
|
rec = client.get(f"/api/recordings/{limited}").json()
|
||||||
|
new_stop = datetime.datetime.fromisoformat(rec["scheduled_stop"]).replace(tzinfo=None)
|
||||||
|
assert abs((new_stop - stop).total_seconds() - 600) < 1
|
||||||
|
|
||||||
|
assert client.post(f"/api/recordings/{limited}/extend?minutes=0").status_code == 422
|
||||||
|
assert client.post(f"/api/recordings/{limited}/extend?minutes=1441").status_code == 422
|
||||||
|
assert client.post("/api/recordings/99999/extend?minutes=5").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_endpoint(client):
|
||||||
|
assert client.post("/api/recordings/99999/stop").status_code == 404
|
||||||
|
done = _add_recording(status="completed")
|
||||||
|
assert client.post(f"/api/recordings/{done}/stop").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_limits(client):
|
||||||
|
assert client.get("/api/logs?limit=0").status_code == 422
|
||||||
|
assert client.get("/api/logs?limit=1001").status_code == 422
|
||||||
|
assert client.get("/api/logs?level=bogus").status_code == 400
|
||||||
|
assert client.get("/api/logs?level=warn").status_code == 200
|
||||||
|
assert client.get("/api/recordings?limit=501").status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_stale(client, app_mod):
|
||||||
|
rid = _add_recording(status="running", pid=999999, output_file="/nonexistent/x.mp3")
|
||||||
|
rid2 = _add_recording(status="starting")
|
||||||
|
app_mod.recorder.cleanup_stale()
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
for i in (rid, rid2):
|
||||||
|
rec = session.get(Recording, i)
|
||||||
|
assert rec.status == "error"
|
||||||
|
assert rec.error_message == "Abgebrochen durch Neustart"
|
||||||
|
assert rec.stopped_at is not None
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
import config as config_module
|
||||||
|
from config import Config, verify_password
|
||||||
|
from models import get_session, Job
|
||||||
|
from scheduler import StreamScheduler
|
||||||
|
|
||||||
|
|
||||||
|
def _add_job(**kw):
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
job = Job(name=kw.pop("name", "j"), stream_url="https://e.example/s", schedule_enabled=True, **kw)
|
||||||
|
session.add(job)
|
||||||
|
session.commit()
|
||||||
|
return job.id
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_legacy_job_does_not_break_tick(client):
|
||||||
|
_add_job(name="kaputt1", schedule_start="00:00", schedule_stop="xx")
|
||||||
|
_add_job(name="kaputt2", schedule_start="25:99", schedule_days="foo-bar")
|
||||||
|
_add_job(name="kaputt3", schedule_start="00:00", schedule_once=True, schedule_date="kein-datum")
|
||||||
|
good = _add_job(name="gut", schedule_start="00:00", schedule_stop="", schedule_days="*")
|
||||||
|
|
||||||
|
rec = mock.MagicMock()
|
||||||
|
sched = StreamScheduler(rec)
|
||||||
|
with mock.patch("scheduler.logger") as log:
|
||||||
|
sched._tick()
|
||||||
|
sched._tick()
|
||||||
|
|
||||||
|
started = [c.kwargs["job_id"] for c in rec.start_recording.call_args_list]
|
||||||
|
assert started == [good]
|
||||||
|
# Fehler nur einmal pro Job geloggt, nicht bei jedem Tick
|
||||||
|
assert log.error.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_started_keys_pruned(client):
|
||||||
|
sched = StreamScheduler(mock.MagicMock())
|
||||||
|
old = (datetime.date.today() - datetime.timedelta(days=5)).isoformat()
|
||||||
|
today = datetime.date.today().isoformat()
|
||||||
|
sched._started_keys = {f"1|False|{old}|10:00", f"2|False|{today}|10:00", "kaputt"}
|
||||||
|
sched._prune_started_keys(datetime.datetime.now())
|
||||||
|
assert sched._started_keys == {f"2|False|{today}|10:00"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_start_generates_password(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(config_module, "PBKDF2_ITERATIONS", 1000)
|
||||||
|
path = tmp_path / "config.yml"
|
||||||
|
cfg = Config(str(path))
|
||||||
|
assert cfg.generated_password
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
assert data["auth"]["enabled"] is True
|
||||||
|
assert "password" not in data["auth"]
|
||||||
|
assert verify_password(cfg.generated_password, data["auth"]["password_hash"])
|
||||||
|
assert cfg.generated_password not in path.read_text(encoding="utf-8")
|
||||||
|
if os.name == "posix":
|
||||||
|
assert (os.stat(path).st_mode & 0o777) == 0o600
|
||||||
|
|
||||||
|
# Zweiter Start: kein neues Passwort
|
||||||
|
assert Config(str(path)).generated_password is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_plaintext_password_migration(tmp_path):
|
||||||
|
path = tmp_path / "config.yml"
|
||||||
|
path.write_text("auth:\n enabled: false\n username: admin\n password: altes-passwort\n", encoding="utf-8")
|
||||||
|
cfg = Config(str(path))
|
||||||
|
assert cfg.generated_password is None
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
assert "password" not in data["auth"]
|
||||||
|
assert data["auth"]["enabled"] is False
|
||||||
|
assert verify_password("altes-passwort", data["auth"]["password_hash"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_default_password_counts_as_unset(tmp_path):
|
||||||
|
path = tmp_path / "config.yml"
|
||||||
|
path.write_text("auth:\n enabled: false\n username: admin\n password: stream-recorder\n", encoding="utf-8")
|
||||||
|
Config(str(path))
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
assert data["auth"]["password_hash"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_default_password_kept_when_auth_enabled(tmp_path):
|
||||||
|
path = tmp_path / "config.yml"
|
||||||
|
path.write_text("auth:\n enabled: true\n username: admin\n password: stream-recorder\n", encoding="utf-8")
|
||||||
|
cfg = Config(str(path))
|
||||||
|
assert cfg.default_password_active is True
|
||||||
|
assert verify_password("stream-recorder", cfg.get("auth", "password_hash"))
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from recorder import RecordingManager, validate_ffmpeg_args, validate_stream_url, PROTOCOL_WHITELIST
|
||||||
|
from scheduler import compute_duration, validate_schedule
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("args", [
|
||||||
|
"",
|
||||||
|
"-c:a libmp3lame -b:a 128k",
|
||||||
|
"-metadata title=Foo/Bar",
|
||||||
|
"-metadata 'title=Mit Leerzeichen'",
|
||||||
|
"-vn -ar 44100 -ac 2",
|
||||||
|
"-af volume=2",
|
||||||
|
])
|
||||||
|
def test_ffmpeg_args_allowed(args):
|
||||||
|
validate_ffmpeg_args(args)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("args", [
|
||||||
|
"/app/app.py",
|
||||||
|
"-i x",
|
||||||
|
"-af amovie=/etc/passwd",
|
||||||
|
"-filter:a AMOVIE=x",
|
||||||
|
"-af sendcmd=f=x",
|
||||||
|
"-c:a",
|
||||||
|
"-c:a -b:a",
|
||||||
|
"-b:a 128k out.mp3",
|
||||||
|
"-y",
|
||||||
|
"-f segment -segment_list /tmp/x",
|
||||||
|
"-metadata ''",
|
||||||
|
"'unclosed",
|
||||||
|
"-vn " * 200,
|
||||||
|
])
|
||||||
|
def test_ffmpeg_args_rejected(args):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_ffmpeg_args(args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ffmpeg_args_error_names_token():
|
||||||
|
with pytest.raises(ValueError, match="/app/app.py"):
|
||||||
|
validate_ffmpeg_args("-vn /app/app.py")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url", [
|
||||||
|
"https://example.com/stream.mp3",
|
||||||
|
"http://radio.example:8000/live",
|
||||||
|
"rtmp://live.example/app/key",
|
||||||
|
"rtsps://cam.example/stream",
|
||||||
|
])
|
||||||
|
def test_url_allowed(url):
|
||||||
|
assert validate_stream_url(url) == url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url", [
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"concat:/etc/passwd|/etc/shadow",
|
||||||
|
"subfile:,,start,0,end,0,,:/etc/passwd",
|
||||||
|
"https://",
|
||||||
|
"http://exa mple.com/",
|
||||||
|
"https://example.com/\nfoo",
|
||||||
|
"ftp://example.com/x",
|
||||||
|
"",
|
||||||
|
"https://example.com/" + "a" * 1000,
|
||||||
|
])
|
||||||
|
def test_url_rejected(url):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_stream_url(url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ffmpeg_cmd_has_protocol_whitelist_before_input():
|
||||||
|
mgr = RecordingManager(config=None)
|
||||||
|
cmd = mgr._build_ffmpeg_cmd("https://example.com/a.m3u8", "hls", "/x/out.mp4",
|
||||||
|
"-metadata title=X", False, None)
|
||||||
|
assert cmd.index("-protocol_whitelist") < cmd.index("-i")
|
||||||
|
assert cmd[cmd.index("-protocol_whitelist") + 1] == PROTOCOL_WHITELIST
|
||||||
|
assert "file" not in PROTOCOL_WHITELIST.split(",")
|
||||||
|
assert cmd[-1] == "/x/out.mp4"
|
||||||
|
assert cmd[-3:-1] == ["-metadata", "title=X"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ffmpeg_cmd_revalidates():
|
||||||
|
mgr = RecordingManager(config=None)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mgr._build_ffmpeg_cmd("https://example.com/a", "http", "/x/out.mp4", "/app/app.py", False, None)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mgr._build_ffmpeg_cmd("file:///etc/passwd", "http", "/x/out.mp4", "", False, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("job,msg", [
|
||||||
|
({"schedule_start": "25:00"}, "HH:MM"),
|
||||||
|
({"schedule_start": "10:00", "schedule_stop": "9:5"}, "HH:MM"),
|
||||||
|
({"schedule_date": "2026-02-30"}, "JJJJ"),
|
||||||
|
({"schedule_days": "mo-xx"}, "Wochentage"),
|
||||||
|
({"schedule_enabled": True}, "Startzeit"),
|
||||||
|
({"schedule_once": True, "schedule_start": "10:00"}, "Datum"),
|
||||||
|
({"schedule_date": "2026-05-02", "schedule_end_date": "2026-05-01"}, "Enddatum"),
|
||||||
|
])
|
||||||
|
def test_schedule_invalid(job, msg):
|
||||||
|
with pytest.raises(ValueError, match=msg):
|
||||||
|
validate_schedule(job)
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_valid():
|
||||||
|
validate_schedule({"schedule_enabled": True, "schedule_start": "23:30", "schedule_stop": "01:00",
|
||||||
|
"schedule_days": "Mo-Fr,SA"})
|
||||||
|
validate_schedule({"schedule_once": True, "schedule_date": "2026-05-01",
|
||||||
|
"schedule_end_date": "2026-05-03", "schedule_start": "10:00"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_duration():
|
||||||
|
assert compute_duration({"max_duration": 120}, apply_buffers=True) == 120
|
||||||
|
assert compute_duration({"schedule_start": "10:00", "schedule_stop": "11:00"}, False) == 3600
|
||||||
|
assert compute_duration({"schedule_start": "23:00", "schedule_stop": "01:00"}, False) == 7200
|
||||||
|
assert compute_duration({"schedule_start": "10:00", "schedule_stop": "11:00",
|
||||||
|
"pre_buffer": 5, "post_buffer": 5}, True) == 4200
|
||||||
|
assert compute_duration({"schedule_start": "10:00", "schedule_stop": ""}, True) is None
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
compute_duration({"schedule_start": "10:00", "schedule_stop": "xx"}, False)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
###############################################
|
||||||
|
# stream-recorder v3 - Konfiguration
|
||||||
|
#
|
||||||
|
# Diese Datei ist eine Vorlage.
|
||||||
|
# Die aktive Konfiguration liegt in ./data/config.yml
|
||||||
|
# und wird beim ersten Start automatisch erstellt.
|
||||||
|
###############################################
|
||||||
|
|
||||||
|
recording:
|
||||||
|
# Basis-Verzeichnis für Aufnahmen
|
||||||
|
# Muss innerhalb von RECORDING_ROOTS liegen (Standard: /app/data/recordings:/recordings)
|
||||||
|
download_path: /app/data/recordings
|
||||||
|
|
||||||
|
# Wiederholungsversuche bei Verbindungsabbruch (0 = unbegrenzt)
|
||||||
|
max_retries: 5
|
||||||
|
|
||||||
|
# Wartezeit zwischen Wiederholungsversuchen (Sekunden)
|
||||||
|
retry_delay: 5
|
||||||
|
|
||||||
|
# Wird nur aus dieser Datei gelesen (nicht über Web-UI/API änderbar)
|
||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8484
|
||||||
|
|
||||||
|
# Basic-Auth (beim ersten Start aktiv, zufälliges Passwort steht im Container-Log)
|
||||||
|
# Das Passwort wird als PBKDF2-Hash (password_hash) gespeichert und über das
|
||||||
|
# Web-UI gesetzt. Alternativ "password: <klartext>" eintragen - der Klartext
|
||||||
|
# wird beim nächsten Start automatisch in einen Hash umgewandelt.
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
username: admin
|
||||||
|
|
||||||
|
# NTFY Push-Benachrichtigungen (optional)
|
||||||
|
# Token und API-Key werden von der API nie zurückgegeben.
|
||||||
|
ntfy:
|
||||||
|
url: ""
|
||||||
|
token: ""
|
||||||
|
events: "error"
|
||||||
|
|
||||||
|
# Plik-Upload (optional)
|
||||||
|
plik:
|
||||||
|
url: ""
|
||||||
|
api_key: ""
|
||||||
|
ttl: "30d"
|
||||||
|
|
||||||
|
# Zeitplan-Puffer (Minuten) – Standardwerte für neue Jobs
|
||||||
|
schedule:
|
||||||
|
default_pre_buffer: 5
|
||||||
|
default_post_buffer: 5
|
||||||
|
|
||||||
|
# Log-Level (DEBUG, INFO, WARN, ERROR)
|
||||||
|
logging:
|
||||||
|
level: INFO
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# stream-recorder - Container-Entrypoint
|
||||||
|
#
|
||||||
|
# Läuft der Container als root, werden UID/GID des Users "app" an PUID/PGID
|
||||||
|
# angepasst, die Datenverzeichnisse übernommen und der Prozess anschließend
|
||||||
|
# ohne Root-Rechte gestartet. Läuft der Container bereits als Nicht-root
|
||||||
|
# (z.B. "user:" in docker-compose), wird der Befehl direkt ausgeführt.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PUID="${PUID:-1000}"
|
||||||
|
PGID="${PGID:-1000}"
|
||||||
|
DATA_DIR="${DATA_DIR:-/app/data}"
|
||||||
|
RECORDING_ROOTS="${RECORDING_ROOTS:-/app/data/recordings:/recordings}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[entrypoint] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nur Dateien/Verzeichnisse umschreiben, die noch nicht PUID:PGID gehören.
|
||||||
|
fix_owner() {
|
||||||
|
if [ -d "$1" ]; then
|
||||||
|
find "$1" \( ! -user "$PUID" -o ! -group "$PGID" \) \
|
||||||
|
-exec chown "$PUID:$PGID" {} + 2>/dev/null \
|
||||||
|
|| log "WARNUNG: Besitzrechte in $1 konnten nicht vollständig angepasst werden"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$(id -u)" != "0" ]; then
|
||||||
|
exec "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$PUID" in
|
||||||
|
''|*[!0-9]*) log "FEHLER: PUID muss numerisch sein (aktuell: $PUID)"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$PGID" in
|
||||||
|
''|*[!0-9]*) log "FEHLER: PGID muss numerisch sein (aktuell: $PGID)"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$PUID" = "0" ]; then
|
||||||
|
log "WARNUNG: PUID=0 - Anwendung läuft als root"
|
||||||
|
exec "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# UID/GID des app-Users anpassen (-o erlaubt bereits vergebene IDs)
|
||||||
|
if [ "$(id -g app)" != "$PGID" ]; then
|
||||||
|
log "Setze GID von app auf $PGID"
|
||||||
|
groupmod -o -g "$PGID" app
|
||||||
|
fi
|
||||||
|
if [ "$(id -u app)" != "$PUID" ]; then
|
||||||
|
log "Setze UID von app auf $PUID"
|
||||||
|
usermod -o -u "$PUID" app
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
fix_owner "$DATA_DIR"
|
||||||
|
|
||||||
|
OLD_IFS="$IFS"
|
||||||
|
IFS=':'
|
||||||
|
for root in $RECORDING_ROOTS; do
|
||||||
|
IFS="$OLD_IFS"
|
||||||
|
if [ -n "$root" ]; then
|
||||||
|
fix_owner "$root"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
IFS="$OLD_IFS"
|
||||||
|
|
||||||
|
if [ -f "$DATA_DIR/config.yml" ]; then
|
||||||
|
chmod 600 "$DATA_DIR/config.yml" || log "WARNUNG: chmod 600 auf config.yml fehlgeschlagen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v setpriv >/dev/null 2>&1; then
|
||||||
|
log "FEHLER: setpriv nicht gefunden (Paket util-linux) - Rechteabgabe nicht möglich"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export HOME=/tmp
|
||||||
|
exec setpriv --reuid="$PUID" --regid="$PGID" --init-groups "$@"
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 135 KiB |
@@ -1,62 +0,0 @@
|
|||||||
###############################################
|
|
||||||
# stream-recorder - Konfigurationsdatei
|
|
||||||
###############################################
|
|
||||||
|
|
||||||
# Basis-Verzeichnis für Aufnahmen
|
|
||||||
# Pro Stream wird ein Unterordner angelegt
|
|
||||||
DOWNLOAD_PATH="/home/downloads/recordings"
|
|
||||||
|
|
||||||
# Log-Verzeichnis
|
|
||||||
LOG_DIR="/var/log/stream-recorder"
|
|
||||||
|
|
||||||
# Log-Level: DEBUG, INFO, WARN, ERROR
|
|
||||||
LOG_LEVEL="INFO"
|
|
||||||
|
|
||||||
# PID-Verzeichnis für laufende Aufnahmen
|
|
||||||
PID_DIR="/tmp/stream-recorder"
|
|
||||||
|
|
||||||
# Persistentes Status-Verzeichnis für geplante Läufe
|
|
||||||
STATE_DIR="/var/lib/stream-recorder"
|
|
||||||
|
|
||||||
# Anzahl Wiederholungsversuche bei Verbindungsabbruch
|
|
||||||
# 0 = unbegrenzt (empfohlen für Langzeitaufnahmen)
|
|
||||||
MAX_RETRIES=5
|
|
||||||
|
|
||||||
# Wartezeit zwischen Wiederholungsversuchen (Sekunden)
|
|
||||||
RETRY_DELAY=5
|
|
||||||
|
|
||||||
###############################################
|
|
||||||
# NTFY-Benachrichtigungen (optional)
|
|
||||||
# https://ntfy.sh oder selbst gehostet
|
|
||||||
###############################################
|
|
||||||
|
|
||||||
# NTFY Server-URL inkl. Topic
|
|
||||||
# Beispiel: https://ntfy.sh/mein-stream-topic
|
|
||||||
# Beispiel: https://ntfy.example.com/recordings
|
|
||||||
# Leer = deaktiviert
|
|
||||||
NTFY_URL=""
|
|
||||||
|
|
||||||
# NTFY Access-Token (optional, nur bei geschützten Topics)
|
|
||||||
NTFY_TOKEN=""
|
|
||||||
|
|
||||||
# Ereignisse, bei denen benachrichtigt wird (kommasepariert)
|
|
||||||
# Möglich: start, stop, error, upload
|
|
||||||
# Beispiel: "start,stop,error,upload" für alle Ereignisse
|
|
||||||
NTFY_EVENTS="error"
|
|
||||||
|
|
||||||
###############################################
|
|
||||||
# Plik-Upload (optional)
|
|
||||||
# https://github.com/root-gg/plik
|
|
||||||
###############################################
|
|
||||||
|
|
||||||
# Plik Server-URL
|
|
||||||
# Beispiel: https://plik.example.com
|
|
||||||
# Leer = deaktiviert (kann pro Job aktiviert werden)
|
|
||||||
PLIK_URL=""
|
|
||||||
|
|
||||||
# Plik API-Key (optional, nur bei geschützten Servern)
|
|
||||||
PLIK_API_KEY=""
|
|
||||||
|
|
||||||
# Gültigkeitsdauer des Uploads
|
|
||||||
# Beispiel: 30d (30 Tage), 24h (24 Stunden), 0 (unbegrenzt)
|
|
||||||
PLIK_TTL="30d"
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
services:
|
||||||
|
stream-recorder:
|
||||||
|
image: git.techniverse.net/scriptos/stream-recorder:latest
|
||||||
|
container_name: stream-recorder
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# Standardmäßig nur lokal erreichbar. Für Zugriff von außen einen
|
||||||
|
# Reverse-Proxy (TLS) davorschalten oder gezielt an eine IP binden,
|
||||||
|
# z.B. eine Tailscale-/VPN-IP: "100.64.0.10:8484:8484"
|
||||||
|
- "127.0.0.1:8484:8484"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
# Optional: Recordings auf separatem Pfad (dann download_path in Einstellungen anpassen).
|
||||||
|
# Der Pfad muss in RECORDING_ROOTS enthalten sein (Standard: /app/data/recordings:/recordings).
|
||||||
|
# - /mnt/storage/recordings:/recordings
|
||||||
|
networks:
|
||||||
|
stream-recorder_net:
|
||||||
|
ipv4_address: 172.16.84.10
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
# UID/GID, unter der die Anwendung läuft (Besitzer von ./data)
|
||||||
|
# PUID: 1000
|
||||||
|
# PGID: 1000
|
||||||
|
# Erlaubte Basisverzeichnisse für download_path (durch ":" getrennt)
|
||||||
|
# RECORDING_ROOTS: /app/data/recordings:/recordings
|
||||||
|
# Logs älter als X Tage werden gelöscht
|
||||||
|
# LOG_RETENTION_DAYS: 90
|
||||||
|
# Swagger-UI unter /docs aktivieren
|
||||||
|
# ENABLE_API_DOCS: 1
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
# Benötigt vom Entrypoint (chown der Daten, Wechsel auf PUID/PGID)
|
||||||
|
- CHOWN
|
||||||
|
- DAC_OVERRIDE
|
||||||
|
- FOWNER
|
||||||
|
- SETUID
|
||||||
|
- SETGID
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8484/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
stream-recorder_net:
|
||||||
|
name: stream-recorder.dockernetwork.local
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.16.84.0/24
|
||||||
|
gateway: 172.16.84.1
|
||||||
|
ip_range: 172.16.84.128/25
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# REST-API
|
||||||
|
|
||||||
|
Alle Funktionen des Web-UI sind auch per REST-API verfügbar. Basis-URL: `http://<host>:8484`
|
||||||
|
|
||||||
|
Die interaktive API-Dokumentation (Swagger-UI unter `/docs`) ist standardmäßig deaktiviert und kann mit der Umgebungsvariable `ENABLE_API_DOCS=1` eingeschaltet werden.
|
||||||
|
|
||||||
|
## Endpunkte
|
||||||
|
|
||||||
|
### System
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `GET` | `/api/health` | Health Check (kein Auth nötig) |
|
||||||
|
| `GET` | `/api/config` | Konfiguration lesen (ohne Secrets) |
|
||||||
|
| `PUT` | `/api/config` | Konfiguration speichern (siehe [Konfiguration](#konfiguration)) |
|
||||||
|
|
||||||
|
### Jobs
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `GET` | `/api/jobs` | Alle Jobs auflisten |
|
||||||
|
| `POST` | `/api/jobs` | Neuen Job erstellen |
|
||||||
|
| `GET` | `/api/jobs/:id` | Job-Details abrufen |
|
||||||
|
| `PUT` | `/api/jobs/:id` | Job aktualisieren |
|
||||||
|
| `DELETE` | `/api/jobs/:id` | Job löschen (nicht während einer laufenden Aufnahme → 400) |
|
||||||
|
| `POST` | `/api/jobs/:id/record` | Job-Aufnahme starten (läuft bereits → 409) |
|
||||||
|
|
||||||
|
### Senderbibliothek
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `GET` | `/api/stations` | Alle gespeicherten Sender auflisten |
|
||||||
|
| `POST` | `/api/stations` | Neuen Sender erstellen |
|
||||||
|
| `GET` | `/api/stations/:id` | Sender-Details abrufen |
|
||||||
|
| `PUT` | `/api/stations/:id` | Sender aktualisieren |
|
||||||
|
| `DELETE` | `/api/stations/:id` | Sender löschen (verknüpfte Jobs werden gelöst) |
|
||||||
|
|
||||||
|
### Schnellaufnahme
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `POST` | `/api/quick-record` | Schnellaufnahme starten |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "rtmp://example.com/live/stream",
|
||||||
|
"name": "Meine Aufnahme",
|
||||||
|
"stream_type": "auto",
|
||||||
|
"max_duration": 3600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Erlaubte URL-Schemata: `http`, `https`, `rtmp`, `rtmps`, `rtsp`, `rtsps`. Für Job-Felder gelten dieselben Prüfungen wie im Web-UI (siehe [Jobs](jobs.md#validierung)).
|
||||||
|
|
||||||
|
### Aufnahmen
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `GET` | `/api/recordings` | Alle Aufnahmen (Parameter: `active_only`, `limit`) |
|
||||||
|
| `GET` | `/api/recordings/:id` | Aufnahme-Status |
|
||||||
|
| `POST` | `/api/recordings/:id/stop` | Aufnahme stoppen |
|
||||||
|
| `POST` | `/api/recordings/:id/extend?minutes=30` | Aufnahme verlängern (`minutes` 1–1440; bei unbegrenzter Aufnahme → 400) |
|
||||||
|
| `POST` | `/api/recordings/:id/unlimited` | Zeitlimit entfernen |
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `GET` | `/api/logs` | Protokoll (Parameter: `limit`, `level`) |
|
||||||
|
|
||||||
|
Zeitstempel in API-Antworten sind UTC mit Offset, z.B. `2026-10-11T14:30:00+00:00`.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
`GET /api/config` liefert keine Secrets. Stattdessen zeigen boolesche Felder an, ob ein Wert gesetzt ist:
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `auth.password_set` | Passwort ist gesetzt |
|
||||||
|
| `ntfy.token_set` | NTFY-Token ist gesetzt |
|
||||||
|
| `plik.api_key_set` | Plik-API-Key ist gesetzt |
|
||||||
|
|
||||||
|
Regeln für `PUT /api/config`:
|
||||||
|
|
||||||
|
- Secret-Felder (`auth.password`, `ntfy.token`, `plik.api_key`) weglassen = unverändert
|
||||||
|
- Leerer String `""` = Secret entfernen (nicht beim Passwort)
|
||||||
|
- `auth.enabled: true` erfordert ein Passwort (mind. 8 Zeichen)
|
||||||
|
- `download_path` muss innerhalb von `RECORDING_ROOTS` liegen
|
||||||
|
- Unbekannte Schlüssel → 422
|
||||||
|
- Der Abschnitt `server` kann nicht über die API geändert werden
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -u admin:passwort -X PUT http://localhost:8484/api/config -H "Content-Type: application/json" -d '{"ntfy": {"url": "https://ntfy.sh/mein-topic", "events": "start,error"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentifizierung
|
||||||
|
|
||||||
|
Bei aktiviertem Auth (Standard bei Neuinstallationen) wird Basic Authentication verwendet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -u admin:passwort http://localhost:8484/api/jobs
|
||||||
|
```
|
||||||
|
|
||||||
|
- Der Health-Endpunkt (`/api/health`) ist immer ohne Auth erreichbar.
|
||||||
|
- 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 |
|
||||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,78 @@
|
|||||||
|
# Aufnahmen
|
||||||
|
|
||||||
|
## Aktive Aufnahmen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Das Dashboard zeigt alle laufenden Aufnahmen mit:
|
||||||
|
- Laufzeit
|
||||||
|
- Dateigröße
|
||||||
|
- Verbleibende Zeit (bei Zeitlimit)
|
||||||
|
- Segment-Anzahl (bei Segment-Splitting)
|
||||||
|
|
||||||
|
## Letzte Aufnahmen
|
||||||
|
|
||||||
|
Unterhalb der aktiven Aufnahmen zeigt das Dashboard die letzten abgeschlossenen Aufnahmen. Per Klick auf eine Aufnahme wird eine Detailansicht ausgeklappt mit:
|
||||||
|
|
||||||
|
- **Status** — abgeschlossen, gestoppt oder Fehler (inkl. Fehlermeldung)
|
||||||
|
- **Start- und Endzeit**
|
||||||
|
- **Dauer und Dateigröße**
|
||||||
|
- **Plik-URL** — Direkter Link zum Download, falls die Aufnahme zu Plik hochgeladen wurde
|
||||||
|
- **Dateipfad** — Lokaler Pfad zur Aufnahmedatei (sofern nicht nach Upload gelöscht)
|
||||||
|
- **Stream-URL**
|
||||||
|
|
||||||
|
## Aufnahme verlängern
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Laufende Aufnahmen können verlängert werden:
|
||||||
|
- **+15 / +30 / +60 / +120 Minuten** — Schnellauswahl
|
||||||
|
- **Eigene Dauer** — Minutenzahl zwischen 1 und 1440 eingeben
|
||||||
|
- **Unbegrenzt** — Zeitlimit komplett entfernen
|
||||||
|
|
||||||
|
Eine bereits unbegrenzte Aufnahme kann nicht weiter verlängert werden.
|
||||||
|
|
||||||
|
## Aufnahme stoppen
|
||||||
|
|
||||||
|
Über den **Stoppen**-Button im Dashboard. Die Aufnahme wird sauber beendet (SIGINT → ffmpeg finalisiert die Datei).
|
||||||
|
|
||||||
|
## Reconnect
|
||||||
|
|
||||||
|
Bei Verbindungsabbrüchen versucht der Recorder automatisch, die Verbindung wiederherzustellen. Konfigurierbar über:
|
||||||
|
- `recording.max_retries` — Maximale Versuche (Standard: 5)
|
||||||
|
- `recording.retry_delay` — Wartezeit zwischen Versuchen (Standard: 5 Sekunden)
|
||||||
|
|
||||||
|
Bei jedem Reconnect wird eine neue Datei angelegt, um Datenverlust zu vermeiden.
|
||||||
|
|
||||||
|
## Neustart
|
||||||
|
|
||||||
|
Aufnahmen, die beim Neustart des Containers liefen, werden beim nächsten Start mit dem Fehler „Abgebrochen durch Neustart" markiert. Geplante Jobs starten wieder regulär nach Zeitplan.
|
||||||
|
|
||||||
|
## Segment-Splitting
|
||||||
|
|
||||||
|
Lange Aufnahmen können automatisch in Teile zerlegt werden. Die Segment-Dauer wird pro Job in Minuten konfiguriert.
|
||||||
|
|
||||||
|
Beispiel: Bei 60 Minuten Segment-Dauer wird eine 3-stündige Aufnahme in 3 Dateien aufgeteilt:
|
||||||
|
```
|
||||||
|
Sendung_20260915_200000_seg000.mp4
|
||||||
|
Sendung_20260915_200000_seg001.mp4
|
||||||
|
Sendung_20260915_200000_seg002.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ausgabeformate
|
||||||
|
|
||||||
|
Das Format wird automatisch anhand des Stream-Typs gewählt:
|
||||||
|
|
||||||
|
| Stream-Typ | Standard-Format |
|
||||||
|
|------------|----------------|
|
||||||
|
| RTMP, HLS, HTTP | mp4 |
|
||||||
|
| MP3 | mp3 |
|
||||||
|
| AAC | aac |
|
||||||
|
| OGG | ogg |
|
||||||
|
| FLAC | flac |
|
||||||
|
|
||||||
|
Kann pro Job manuell überschrieben werden (z.B. `mkv`, `ts`).
|
||||||
|
|
||||||
|
## Speicherort
|
||||||
|
|
||||||
|
Aufnahmen werden unter `data/recordings/<job-name>/` gespeichert (im Container: `/app/data/recordings/`). Der Pfad ist über `recording.download_path` konfigurierbar und muss innerhalb eines Verzeichnisses aus `RECORDING_ROOTS` liegen (Standard: `/app/data/recordings:/recordings`), siehe [Konfiguration](konfiguration.md#umgebungsvariablen).
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# Befehle
|
|
||||||
|
|
||||||
## Übersicht
|
|
||||||
|
|
||||||
| Befehl | Beschreibung |
|
|
||||||
|----------------------|-------------------------------------------------|
|
|
||||||
| `record [job]` | Einzelnen Stream aufzeichnen (ohne Angabe: Auswahl) |
|
|
||||||
| `record-all` | Alle Jobs gleichzeitig aufzeichnen |
|
|
||||||
| `create` | Neuen Job interaktiv erstellen |
|
|
||||||
| `quick` | Schnellaufnahme (URL direkt eingeben) |
|
|
||||||
| `upload <datei>` | Vorhandene Datei nach Plik hochladen |
|
|
||||||
| `stop [name]` | Aufnahme stoppen (ohne Angabe: Auswahl) |
|
|
||||||
| `stop-all` | Alle Aufnahmen stoppen |
|
|
||||||
| `list` | Jobs und deren Zeitpläne anzeigen |
|
|
||||||
| `status` | Laufende Aufnahmen mit Details anzeigen |
|
|
||||||
| `scheduler` | Zeitplanung prüfen und Jobs starten/stoppen |
|
|
||||||
| `logs` | Log-Ausgabe live verfolgen |
|
|
||||||
|
|
||||||
## Optionen
|
|
||||||
|
|
||||||
| Option | Beschreibung |
|
|
||||||
|--------------------------|---------------------------------------|
|
|
||||||
| `-c, --config <datei>` | Alternative Konfigurationsdatei |
|
|
||||||
| `-V, --verbose` | Ausführliche Ausgabe |
|
|
||||||
| `--foreground` | Aufnahme im Vordergrund ausführen |
|
|
||||||
| `-h, --help` | Hilfe anzeigen |
|
|
||||||
| `-v, --version` | Version anzeigen |
|
|
||||||
|
|
||||||
## Details
|
|
||||||
|
|
||||||
### record
|
|
||||||
|
|
||||||
Startet eine Aufnahme im Hintergrund (Standard) oder Vordergrund (`--foreground`). Ohne Job-Name wird eine interaktive Auswahl aller verfügbaren Jobs angezeigt.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./stream-recorder.sh record # Job aus Liste wählen
|
|
||||||
./stream-recorder.sh record mein-stream # Direkt starten
|
|
||||||
./stream-recorder.sh --foreground record mein-stream
|
|
||||||
./stream-recorder.sh -V record mein-stream
|
|
||||||
```
|
|
||||||
|
|
||||||
### create
|
|
||||||
|
|
||||||
Erstellt interaktiv eine neue Job-Datei. Fragt nach URL, Name, Typ, Segment-Splitting, Zeitplan und Plik-Upload.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./stream-recorder.sh create
|
|
||||||
```
|
|
||||||
|
|
||||||
### quick
|
|
||||||
|
|
||||||
Schnellaufnahme ohne Job-Datei. Fragt nach URL, Name, Typ und Dauer.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./stream-recorder.sh quick
|
|
||||||
```
|
|
||||||
|
|
||||||
### stop / stop-all
|
|
||||||
|
|
||||||
Stoppt eine laufende Aufnahme. Ohne Job-Name wird eine Auswahl angezeigt. Wenn Plik aktiviert ist, wird die Teilaufnahme hochgeladen. Bei Segment-Splitting werden alle verbleibenden Segmente hochgeladen.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./stream-recorder.sh stop # Job aus Liste wählen
|
|
||||||
./stream-recorder.sh stop mein-stream # Direkt stoppen
|
|
||||||
./stream-recorder.sh stop-all # Alle stoppen
|
|
||||||
```
|
|
||||||
|
|
||||||
### status
|
|
||||||
|
|
||||||
Zeigt detaillierte Informationen zu laufenden Aufnahmen:
|
|
||||||
- Stream-Name und Typ
|
|
||||||
- PID und Startmodus (manuell/geplant)
|
|
||||||
- Startzeit und bisherige Laufzeit
|
|
||||||
- Dateigröße bzw. Anzahl der Segmente und Gesamtgröße
|
|
||||||
- Scheduler-Status (systemd-Timer)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./stream-recorder.sh status
|
|
||||||
```
|
|
||||||
|
|
||||||
### list
|
|
||||||
|
|
||||||
Zeigt alle konfigurierten Jobs mit Status, Zeitplan, Segment-Einstellungen und Plik-Konfiguration.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./stream-recorder.sh list
|
|
||||||
```
|
|
||||||
|
|
||||||
### logs
|
|
||||||
|
|
||||||
Verfolgt die Log-Ausgabe in Echtzeit (`tail -f`).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./stream-recorder.sh logs
|
|
||||||
```
|
|
||||||
|
|
||||||
## Logging
|
|
||||||
|
|
||||||
Logs werden nach `LOG_DIR` geschrieben (Standard: `/var/log/stream-recorder/`):
|
|
||||||
|
|
||||||
| Datei | Inhalt |
|
|
||||||
|----------------------------|---------------------------------|
|
|
||||||
| `stream-recorder.log` | Alle Operationen des Scripts |
|
|
||||||
| `ffmpeg_<job-id>.log` | ffmpeg-Ausgabe pro Job |
|
|
||||||
| `nohup_<job-id>.log` | Ausgabe im Hintergrund-Modus |
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
# Praxisbeispiele
|
|
||||||
|
|
||||||
## RTMP-Livestream täglich aufzeichnen
|
|
||||||
|
|
||||||
Tägliche Aufnahme eines Twitch-/YouTube-/RTMP-Streams von 20:00 bis 22:00 Uhr mit Plik-Upload:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# jobs/abend-livestream.job
|
|
||||||
STREAM_NAME="Abend-Livestream"
|
|
||||||
STREAM_URL="rtmp://example.com/live/stream-key"
|
|
||||||
STREAM_TYPE="auto"
|
|
||||||
OUTPUT_FORMAT=""
|
|
||||||
MAX_DURATION=""
|
|
||||||
EXTRA_FFMPEG_ARGS=""
|
|
||||||
|
|
||||||
SEGMENT_DURATION=""
|
|
||||||
|
|
||||||
SCHEDULE_ENABLED=true
|
|
||||||
SCHEDULE_DAYS="*"
|
|
||||||
SCHEDULE_START="20:00"
|
|
||||||
SCHEDULE_STOP="22:00"
|
|
||||||
|
|
||||||
PLIK_ENABLED=true
|
|
||||||
DELETE_AFTER_UPLOAD=false
|
|
||||||
```
|
|
||||||
|
|
||||||
## Radio-Mitschnitt werktags
|
|
||||||
|
|
||||||
Werktags um 18:00 Uhr eine Stunde Radio mitschneiden:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# jobs/radio-abends.job
|
|
||||||
STREAM_NAME="Radio-Abendshow"
|
|
||||||
STREAM_URL="https://example.com/stream.mp3"
|
|
||||||
STREAM_TYPE="auto"
|
|
||||||
OUTPUT_FORMAT=""
|
|
||||||
MAX_DURATION="1h"
|
|
||||||
EXTRA_FFMPEG_ARGS=""
|
|
||||||
|
|
||||||
SEGMENT_DURATION=""
|
|
||||||
|
|
||||||
SCHEDULE_ENABLED=true
|
|
||||||
SCHEDULE_DAYS="Mo-Fr"
|
|
||||||
SCHEDULE_START="18:00"
|
|
||||||
SCHEDULE_STOP=""
|
|
||||||
|
|
||||||
PLIK_ENABLED=false
|
|
||||||
```
|
|
||||||
|
|
||||||
## 24/7-Stream mit stündlichem Upload
|
|
||||||
|
|
||||||
Ein Überwachungsstream, der dauerhaft läuft und stündlich Segmente zu Plik hochlädt. Lokale Dateien werden nach dem Upload gelöscht:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# jobs/ueberwachung.job
|
|
||||||
STREAM_NAME="Kamera-Front"
|
|
||||||
STREAM_URL="rtmp://192.168.1.100/live/cam1"
|
|
||||||
STREAM_TYPE="auto"
|
|
||||||
OUTPUT_FORMAT="mp4"
|
|
||||||
MAX_DURATION=""
|
|
||||||
EXTRA_FFMPEG_ARGS=""
|
|
||||||
|
|
||||||
SEGMENT_DURATION="1h"
|
|
||||||
|
|
||||||
SCHEDULE_ENABLED=false
|
|
||||||
|
|
||||||
PLIK_ENABLED=true
|
|
||||||
DELETE_AFTER_UPLOAD=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Starten mit:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./stream-recorder.sh record ueberwachung
|
|
||||||
```
|
|
||||||
|
|
||||||
## Podcast-/HLS-Stream in 2-Stunden-Blöcken
|
|
||||||
|
|
||||||
Ein langer HLS-Livestream (z.B. Konferenz) in 2-Stunden-Segmenten aufzeichnen, jedes Segment wird sofort hochgeladen:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# jobs/konferenz.job
|
|
||||||
STREAM_NAME="Dev-Konferenz-2026"
|
|
||||||
STREAM_URL="https://example.com/live/playlist.m3u8"
|
|
||||||
STREAM_TYPE="hls"
|
|
||||||
OUTPUT_FORMAT="mp4"
|
|
||||||
MAX_DURATION="10h"
|
|
||||||
EXTRA_FFMPEG_ARGS=""
|
|
||||||
|
|
||||||
SEGMENT_DURATION="2h"
|
|
||||||
|
|
||||||
SCHEDULE_ENABLED=false
|
|
||||||
|
|
||||||
PLIK_ENABLED=true
|
|
||||||
DELETE_AFTER_UPLOAD=false
|
|
||||||
```
|
|
||||||
|
|
||||||
## Einmalige Aufnahme zu einem bestimmten Termin
|
|
||||||
|
|
||||||
Ein Event am 15.09.2026 um 21:00 Uhr aufnehmen, 2 Stunden:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# jobs/einmaliges-event.job
|
|
||||||
STREAM_NAME="Einmaliges-Event"
|
|
||||||
STREAM_URL="https://example.com/live/playlist.m3u8"
|
|
||||||
STREAM_TYPE="hls"
|
|
||||||
OUTPUT_FORMAT="mp4"
|
|
||||||
MAX_DURATION="2h"
|
|
||||||
EXTRA_FFMPEG_ARGS=""
|
|
||||||
|
|
||||||
SEGMENT_DURATION=""
|
|
||||||
|
|
||||||
SCHEDULE_ENABLED=true
|
|
||||||
SCHEDULE_ONCE=true
|
|
||||||
SCHEDULE_DATE="2026-09-15"
|
|
||||||
SCHEDULE_START="21:00"
|
|
||||||
SCHEDULE_STOP=""
|
|
||||||
|
|
||||||
PLIK_ENABLED=true
|
|
||||||
DELETE_AFTER_UPLOAD=false
|
|
||||||
```
|
|
||||||
|
|
||||||
## Schnellaufnahme ohne Job-Datei
|
|
||||||
|
|
||||||
Für spontane Aufnahmen ohne Konfiguration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./stream-recorder.sh quick
|
|
||||||
```
|
|
||||||
|
|
||||||
Das Script fragt interaktiv nach URL, Name, Typ und Dauer.
|
|
||||||
|
|
||||||
## Typischer Workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Konfiguration erstellen (einmalig)
|
|
||||||
cp config/stream-recorder.conf.dist config/stream-recorder.conf
|
|
||||||
nano config/stream-recorder.conf
|
|
||||||
|
|
||||||
# 2. Scheduler installieren (einmalig)
|
|
||||||
sudo install/install.sh
|
|
||||||
|
|
||||||
# 3. Job erstellen
|
|
||||||
./stream-recorder.sh create
|
|
||||||
|
|
||||||
# 4. Jobs anzeigen
|
|
||||||
./stream-recorder.sh list
|
|
||||||
|
|
||||||
# 5. Status prüfen
|
|
||||||
./stream-recorder.sh status
|
|
||||||
|
|
||||||
# 6. Logs verfolgen
|
|
||||||
./stream-recorder.sh logs
|
|
||||||
|
|
||||||
# 7. Aufnahme stoppen (falls nötig)
|
|
||||||
./stream-recorder.sh stop mein-stream
|
|
||||||
```
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# NTFY-Benachrichtigungen
|
|
||||||
|
|
||||||
Push-Nachrichten via [ntfy](https://ntfy.sh) (selbst gehostet oder öffentlich) bei wichtigen Ereignissen.
|
|
||||||
|
|
||||||
## Einrichtung
|
|
||||||
|
|
||||||
In `config/stream-recorder.conf`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NTFY_URL="https://ntfy.sh/mein-stream-topic"
|
|
||||||
NTFY_TOKEN="" # Optional, nur bei geschützten Topics
|
|
||||||
NTFY_EVENTS="start,stop,error" # Kommasepariert
|
|
||||||
```
|
|
||||||
|
|
||||||
## Events
|
|
||||||
|
|
||||||
| Event | Auslöser | Priorität |
|
|
||||||
|----------|-----------------------------------------------------|-----------|
|
|
||||||
| `start` | Aufnahme wurde gestartet | Normal |
|
|
||||||
| `stop` | Aufnahme beendet (regulär oder manuell) | Normal |
|
|
||||||
| `error` | Verbindung verloren oder max. Versuche erreicht | Hoch |
|
|
||||||
| `upload` | Datei auf Plik hochgeladen (mit Download-Link) | Normal |
|
|
||||||
|
|
||||||
## Alle Events aktivieren
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NTFY_EVENTS="start,stop,error,upload"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Nur Fehler (Standard)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NTFY_EVENTS="error"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hinweise
|
|
||||||
|
|
||||||
- Benachrichtigungen werden asynchron im Hintergrund gesendet
|
|
||||||
- Ein fehlgeschlagener Versand blockiert die Aufnahme nicht
|
|
||||||
- curl muss installiert sein (`sudo apt install curl`)
|
|
||||||
- Ohne gesetzte `NTFY_URL` sind Benachrichtigungen komplett deaktiviert
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# Installation
|
|
||||||
|
|
||||||
## Voraussetzungen
|
|
||||||
|
|
||||||
| Komponente | Anforderung |
|
|
||||||
|------------------|------------------------------------------|
|
|
||||||
| Betriebssystem | Linux (getestet unter Ubuntu/Debian) |
|
|
||||||
| Bash | Version 4.0 oder neuer |
|
|
||||||
| ffmpeg | Pflicht (`sudo apt install ffmpeg`) |
|
|
||||||
| curl | Optional, für NTFY und Plik-Upload |
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Repository klonen
|
|
||||||
git clone https://git.techniverse.net/scriptos/stream-recorder.git /opt/stream-recorder
|
|
||||||
cd /opt/stream-recorder
|
|
||||||
|
|
||||||
# Konfiguration aus Vorlage erstellen und anpassen
|
|
||||||
cp config/stream-recorder.conf.dist config/stream-recorder.conf
|
|
||||||
nano config/stream-recorder.conf
|
|
||||||
|
|
||||||
# Scheduler installieren (systemd-Timer)
|
|
||||||
sudo install/install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Das Install-Script:
|
|
||||||
- Macht das Hauptscript ausführbar
|
|
||||||
- Installiert fehlende Abhängigkeiten (ffmpeg)
|
|
||||||
- Erstellt die Konfigurationsdatei aus der Vorlage (falls nicht vorhanden)
|
|
||||||
- Richtet einen systemd-Timer ein, der jede Minute die Zeitpläne prüft
|
|
||||||
|
|
||||||
## Updates
|
|
||||||
|
|
||||||
Da die Konfiguration als Kopie (`stream-recorder.conf`) vorliegt und die Vorlage (`stream-recorder.conf.dist`) getrennt gepflegt wird, gehen eigene Einstellungen bei einem `git pull` nicht verloren.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/stream-recorder
|
|
||||||
git pull
|
|
||||||
```
|
|
||||||
|
|
||||||
Nach einem Update sollte man prüfen, ob in `stream-recorder.conf.dist` neue Parameter hinzugekommen sind, und diese ggf. in die eigene `stream-recorder.conf` übernehmen.
|
|
||||||
|
|
||||||
## Deinstallation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo install/uninstall.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Entfernt den systemd-Timer und stoppt optional alle laufenden Aufnahmen. Job-Dateien, Konfiguration und Aufnahmen bleiben erhalten.
|
|
||||||
|
|
||||||
## Ohne systemd
|
|
||||||
|
|
||||||
Das Script funktioniert auch ohne Scheduler. In dem Fall muss man Aufnahmen manuell starten:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./stream-recorder.sh record mein-stream
|
|
||||||
```
|
|
||||||
|
|
||||||
Oder den Scheduler manuell per Cron aufrufen:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# crontab -e
|
|
||||||
* * * * * /opt/stream-recorder/stream-recorder.sh scheduler
|
|
||||||
```
|
|
||||||
@@ -1,80 +1,124 @@
|
|||||||
# Job-Dateien
|
# Jobs
|
||||||
|
|
||||||
Jeder Stream wird durch eine `.job`-Datei im `jobs/`-Verzeichnis definiert. Vorlagen befinden sich unter `jobs/templates/`.
|

|
||||||
|
|
||||||
|
Jobs sind vorkonfigurierte Aufnahme-Vorlagen. Jeder Job definiert einen Stream, optional einen Zeitplan und Plugin-Einstellungen.
|
||||||
|
|
||||||
## Job erstellen
|
## Job erstellen
|
||||||
|
|
||||||
### Interaktiv (empfohlen)
|
Im Web-UI unter **Jobs → + Neuer Job**.
|
||||||
|
|
||||||
```bash
|
### Pflichtfelder
|
||||||
./stream-recorder.sh create
|
|
||||||
|
| Feld | Beschreibung |
|
||||||
|
|------|-------------|
|
||||||
|
| **Name** | Eindeutiger Name (wird als Ordnername verwendet) |
|
||||||
|
| **Stream-URL** | URL des Streams. Erlaubt: `http`, `https`, `rtmp`, `rtmps`, `rtsp`, `rtsps` |
|
||||||
|
|
||||||
|
### Optionale Felder
|
||||||
|
|
||||||
|
| Feld | Beschreibung |
|
||||||
|
|------|-------------|
|
||||||
|
| **Senderbibliothek** | Sender aus der Bibliothek auswählen — URL und Typ werden übernommen |
|
||||||
|
| **Beschreibung** | Freitext-Notiz zum Job (wird auch in NTFY-Nachrichten mitgeschickt) |
|
||||||
|
| **Typ** | Automatisch erkannt aus URL. Manuell wählbar: RTMP, HLS, MP3, AAC, OGG, HTTP |
|
||||||
|
| **Ausgabeformat** | Dateiendung. Leer = automatisch (mp4 für Video, mp3 für Audio) |
|
||||||
|
| **Max. Dauer** | Zeitlimit in Minuten. Entfällt bei Zeitplan mit Endzeit |
|
||||||
|
| **Segment-Dauer** | Aufnahme in Teile zerlegen (Minuten pro Segment) |
|
||||||
|
| **Extra ffmpeg Argumente** | Zusätzliche ffmpeg-Parameter (z.B. `-b:a 192k`), 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"
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Script fragt Schritt für Schritt nach URL, Name, Typ, Segment-Splitting, Zeitplan und Plik-Upload und erstellt die Job-Datei automatisch.
|
## Validierung
|
||||||
|
|
||||||
### Aus Vorlage
|
| 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 |
|
||||||
|
|
||||||
```bash
|
Ungültige Eingaben werden mit einer Fehlermeldung abgelehnt (API: 400/422).
|
||||||
# Vorlage kopieren
|
|
||||||
cp jobs/templates/example-rtmp.job.dist jobs/mein-stream.job
|
|
||||||
|
|
||||||
# Anpassen
|
## Zeitplan
|
||||||
nano jobs/mein-stream.job
|
|
||||||
```
|
|
||||||
|
|
||||||
Verfügbare Vorlagen:
|
Aktiviert automatische Aufnahmen zu festgelegten Zeiten.
|
||||||
|
|
||||||
| Vorlage | Beschreibung |
|
| Feld | Beschreibung |
|
||||||
|----------------------------|-------------------------|
|
|------|-------------|
|
||||||
| `example-rtmp.job.dist` | RTMP/RTMPS-Stream |
|
| **Einmalig** | Nur einmal am angegebenen Datum ausführen |
|
||||||
| `example-hls.job.dist` | HLS/m3u8-Stream |
|
| **Startdatum** | Datum für einmalige Aufnahme |
|
||||||
| `example-mp3.job.dist` | HTTP-Audio (MP3/Radio) |
|
| **Enddatum** | Enddatum für mehrtägige Aufnahmen (leer = gleicher Tag) |
|
||||||
|
| **Tage** | Wochentage: `*` (täglich), `Mo-Fr`, `Sa,So`, `Mo,Mi,Fr` |
|
||||||
|
| **Startzeit** | Aufnahme-Beginn (HH:MM) |
|
||||||
|
| **Endzeit** | Aufnahme-Ende (HH:MM) — Dauer wird automatisch berechnet |
|
||||||
|
|
||||||
## Stream-Parameter
|
Wenn Start- und Endzeit gesetzt sind, wird die Aufnahmedauer automatisch berechnet. Das Feld "Max. Dauer" wird dann nicht benötigt.
|
||||||
|
|
||||||
| Parameter | Pflicht | Beschreibung |
|
### Tage-Syntax
|
||||||
|----------------------|---------|-----------------------------------------------------|
|
|
||||||
| `STREAM_NAME` | Ja | Name des Streams (wird für Dateinamen verwendet) |
|
|
||||||
| `STREAM_URL` | Ja | URL des Streams |
|
|
||||||
| `STREAM_TYPE` | Nein | `auto`, `rtmp`, `hls`, `mp3`, `aac`, `ogg`, `flac` |
|
|
||||||
| `OUTPUT_FORMAT` | Nein | Dateiendung (z.B. `mp4`, `mp3`) — sonst automatisch |
|
|
||||||
| `MAX_DURATION` | Nein | Aufnahmedauer (z.B. `1h`, `30m`, `1d12h`, `3600`) |
|
|
||||||
| `EXTRA_FFMPEG_ARGS` | Nein | Zusätzliche ffmpeg-Parameter |
|
|
||||||
| `SEGMENT_DURATION` | Nein | Segment-Splitting (z.B. `1h`, `2h`) — siehe [Segment-Splitting](segment-splitting.md) |
|
|
||||||
| `PLIK_ENABLED` | Nein | Plik-Upload aktivieren (`true`/`false`) |
|
|
||||||
| `DELETE_AFTER_UPLOAD` | Nein | Lokale Datei nach Upload löschen (`true`/`false`) |
|
|
||||||
|
|
||||||
### Dauerformate
|
| Eingabe | Bedeutung |
|
||||||
|
|---------|-----------|
|
||||||
|
| `*` | Jeden Tag |
|
||||||
|
| `Mo-Fr` | Montag bis Freitag |
|
||||||
|
| `Sa,So` | Samstag und Sonntag |
|
||||||
|
| `Mo,Mi,Fr` | Montag, Mittwoch, Freitag |
|
||||||
|
| `Mo-Mi,Fr` | Montag bis Mittwoch und Freitag |
|
||||||
|
|
||||||
Die Dauer kann in verschiedenen Formaten angegeben werden:
|
Unterstützt: `Mo/Mon/Montag`, `Di/Tue/Dienstag`, `Mi/Wed/Mittwoch`, `Do/Thu/Donnerstag`, `Fr/Fri/Freitag`, `Sa/Sat/Samstag`, `So/Sun/Sonntag`
|
||||||
|
|
||||||
| Format | Bedeutung |
|
### Zeitpuffer
|
||||||
|-----------|------------------|
|
|
||||||
| `3600` | 3600 Sekunden |
|
|
||||||
| `30m` | 30 Minuten |
|
|
||||||
| `1h` | 1 Stunde |
|
|
||||||
| `1h30m` | 90 Minuten |
|
|
||||||
| `48h` | 48 Stunden |
|
|
||||||
| `48h30m` | 48,5 Stunden |
|
|
||||||
| `2d` | 2 Tage |
|
|
||||||
| `1d12h` | 36 Stunden |
|
|
||||||
|
|
||||||
Formate lassen sich frei kombinieren: `d` (Tage), `h` (Stunden), `m` (Minuten), `s` (Sekunden).
|

|
||||||
|
|
||||||
## Job-Angabe beim Starten
|
Pro Job können Vor- und Nachlaufzeit konfiguriert werden, um die Aufnahme um den geplanten Zeitraum herum zu erweitern:
|
||||||
|
|
||||||
Der `record`-Befehl akzeptiert verschiedene Formate:
|
| Feld | Beschreibung |
|
||||||
|
|------|-------------|
|
||||||
|
| **Vorlaufzeit** | Aufnahme startet X Minuten vor der geplanten Startzeit |
|
||||||
|
| **Nachlaufzeit** | Aufnahme läuft X Minuten über die geplante Endzeit hinaus |
|
||||||
|
|
||||||
```bash
|
Die Standardwerte können in den Einstellungen unter **Zeitplan-Puffer** festgelegt werden.
|
||||||
./stream-recorder.sh record jobs/mein-stream.job # Vollständiger Pfad
|
|
||||||
./stream-recorder.sh record mein-stream.job # Nur Dateiname
|
|
||||||
./stream-recorder.sh record mein-stream # Nur Job-Name (ohne .job)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Jobs anzeigen
|
### Mehrtägige Aufnahmen
|
||||||
|
|
||||||
```bash
|
Bei einmaligen Aufnahmen kann ein **Enddatum** angegeben werden, das nach dem Startdatum liegt. Die Aufnahmedauer wird dann automatisch über die Tagesgrenzen hinweg berechnet.
|
||||||
./stream-recorder.sh list
|
|
||||||
```
|
|
||||||
|
|
||||||
Zeigt alle Jobs mit Stream-Typ, URL, Status, Zeitplan, Segment-Einstellungen und Plik-Konfiguration.
|
## Plugins pro Job
|
||||||
|
|
||||||
|
Jeder Job kann individuell Plugin-Funktionen aktivieren:
|
||||||
|
|
||||||
|
| Option | Beschreibung |
|
||||||
|
|--------|-------------|
|
||||||
|
| **Benachrichtigungen (NTFY)** | Push-Nachrichten bei Start, Stop und Fehlern für diesen Job |
|
||||||
|
| **Plik-Upload** | Automatischer Upload nach Aufnahme-Ende |
|
||||||
|
| **File nach Upload lokal löschen** | Lokale Datei nach erfolgreichem Plik-Upload entfernen |
|
||||||
|
|
||||||
|
Die globale NTFY- und Plik-Konfiguration (URL, Token, etc.) erfolgt in den [Einstellungen](konfiguration.md).
|
||||||
|
|
||||||
|
## Manuelle Aufnahme
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Für einmalige Aufnahmen ohne Job: **Dashboard → + Schnellaufnahme**. URL eingeben (oder aus der Senderbibliothek wählen), optional Name und Dauer angeben, sofort starten.
|
||||||
|
|||||||
@@ -1,66 +1,140 @@
|
|||||||
# Konfiguration
|
# Konfiguration
|
||||||
|
|
||||||
Die Konfigurationsdatei liegt unter `config/stream-recorder.conf`. Sie wird aus der Vorlage `config/stream-recorder.conf.dist` erstellt:
|
## config.yml
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Die Konfiguration liegt in `data/config.yml` und kann über das Web-UI unter **Einstellungen** bearbeitet werden.
|
||||||
|
|
||||||
|
Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine Vorlage mit Kommentaren findet sich in `bin/config.yml.dist`.
|
||||||
|
|
||||||
|
Die Datei enthält Secrets und wird beim Containerstart auf die Rechte `600` gesetzt.
|
||||||
|
|
||||||
|
## Abschnitte
|
||||||
|
|
||||||
|
### Aufnahme
|
||||||
|
|
||||||
|
| Parameter | Standard | Beschreibung |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| `recording.download_path` | `/app/data/recordings` | Speicherort für Aufnahmen. Muss innerhalb eines Verzeichnisses aus `RECORDING_ROOTS` liegen |
|
||||||
|
| `recording.max_retries` | `5` | Max. Reconnect-Versuche bei Verbindungsabbruch (0 = unbegrenzt) |
|
||||||
|
| `recording.retry_delay` | `5` | Wartezeit (Sekunden) zwischen Reconnect-Versuchen |
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
| Parameter | Standard | Beschreibung |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| `server.host` | `0.0.0.0` | Bind-Adresse des Webservers |
|
||||||
|
| `server.port` | `8484` | Port des Webservers |
|
||||||
|
|
||||||
|
Der Abschnitt `server` wird nur aus der Datei gelesen und kann nicht über Web-UI/API geändert werden. Da die Anwendung nicht als root läuft, sind Ports unter 1024 nicht möglich.
|
||||||
|
|
||||||
|
### Authentifizierung
|
||||||
|
|
||||||
|
| Parameter | Standard | Beschreibung |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| `auth.enabled` | `true` | Basic Auth aktivieren (erfordert ein Passwort mit mind. 8 Zeichen) |
|
||||||
|
| `auth.username` | `admin` | Benutzername |
|
||||||
|
| `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
|
```bash
|
||||||
cp config/stream-recorder.conf.dist config/stream-recorder.conf
|
docker logs stream-recorder
|
||||||
nano config/stream-recorder.conf
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Die `.conf`-Datei wird von Git ignoriert und bleibt bei Updates erhalten.
|
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.
|
||||||
|
|
||||||
## Parameter
|
**Passwort vergessen:** In `data/config.yml` den Eintrag `password_hash` durch `password: neues-passwort` ersetzen und den Container neu starten.
|
||||||
|
|
||||||
### Basis-Einstellungen
|
### Zeitplan-Puffer
|
||||||
|
|
||||||
|
Standardwerte für die Vor- und Nachlaufzeit bei neuen Jobs mit Zeitplan.
|
||||||
|
|
||||||
| Parameter | Standard | Beschreibung |
|
| Parameter | Standard | Beschreibung |
|
||||||
|-----------------|------------------------------|-------------------------------------------|
|
|-----------|----------|-------------|
|
||||||
| `DOWNLOAD_PATH` | `/home/downloads/recordings` | Basis-Verzeichnis für Aufnahmen |
|
| `schedule.default_pre_buffer` | `5` | Standard-Vorlaufzeit (Minuten) |
|
||||||
| `LOG_DIR` | `/var/log/stream-recorder` | Log-Verzeichnis |
|
| `schedule.default_post_buffer` | `5` | Standard-Nachlaufzeit (Minuten) |
|
||||||
| `LOG_LEVEL` | `INFO` | Log-Level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
|
|
||||||
| `PID_DIR` | `/tmp/stream-recorder` | PID-Dateien für laufende Aufnahmen |
|
|
||||||
| `STATE_DIR` | `/var/lib/stream-recorder` | Status für bereits gestartete Zeitpläne |
|
|
||||||
|
|
||||||
### Reconnect
|
### NTFY
|
||||||
|
|
||||||
| Parameter | Standard | Beschreibung |
|
| Parameter | Standard | Beschreibung |
|
||||||
|---------------|----------|-------------------------------------------|
|
|-----------|----------|-------------|
|
||||||
| `MAX_RETRIES` | `0` | Wiederholungsversuche (0 = unbegrenzt) |
|
| `ntfy.url` | _(leer)_ | NTFY Topic-URL (z.B. `https://ntfy.sh/mein-topic`) |
|
||||||
| `RETRY_DELAY` | `5` | Wartezeit zwischen Versuchen (Sekunden) |
|
| `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` |
|
||||||
|
|
||||||
Bei Verbindungsabbrüchen versucht das Script automatisch, die Verbindung wiederherzustellen. Bei jedem Reconnect wird eine neue Datei mit aktuellem Zeitstempel erzeugt.
|
### Plik
|
||||||
|
|
||||||
- `MAX_RETRIES=0` — Unbegrenzte Versuche (empfohlen für Langzeitaufnahmen)
|
|
||||||
- `MAX_RETRIES=5` — Maximal 5 Versuche, danach Abbruch mit Benachrichtigung
|
|
||||||
|
|
||||||
### NTFY-Benachrichtigungen
|
|
||||||
|
|
||||||
Push-Nachrichten via [ntfy](https://ntfy.sh) (selbst gehostet oder öffentlich).
|
|
||||||
|
|
||||||
| Parameter | Standard | Beschreibung |
|
| Parameter | Standard | Beschreibung |
|
||||||
|---------------|----------|-------------------------------------------|
|
|-----------|----------|-------------|
|
||||||
| `NTFY_URL` | (leer) | NTFY Server-URL inkl. Topic |
|
| `plik.url` | _(leer)_ | Plik-Server URL (z.B. `https://plik.example.com`) |
|
||||||
| `NTFY_TOKEN` | (leer) | NTFY Access-Token (optional) |
|
| `plik.api_key` | _(leer)_ | API Key für authentifizierte Uploads (wird von der API nie zurückgegeben) |
|
||||||
| `NTFY_EVENTS` | `error` | Benachrichtigungs-Events (kommasepariert) |
|
| `plik.ttl` | `30d` | Aufbewahrungsdauer der Uploads (`30d`, `12h`, etc.) |
|
||||||
|
|
||||||
Siehe [Benachrichtigungen](benachrichtigungen.md) für Details zu den Events.
|
### Logging
|
||||||
|
|
||||||
### Plik-Upload
|
|
||||||
|
|
||||||
Fertige Aufnahmen auf einen [Plik](https://github.com/root-gg/plik)-Server hochladen.
|
|
||||||
|
|
||||||
| Parameter | Standard | Beschreibung |
|
| Parameter | Standard | Beschreibung |
|
||||||
|----------------|----------|-------------------------------------------|
|
|-----------|----------|-------------|
|
||||||
| `PLIK_URL` | (leer) | Plik Server-URL |
|
| `logging.level` | `INFO` | Log-Level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
|
||||||
| `PLIK_API_KEY` | (leer) | Plik API-Key (optional) |
|
|
||||||
| `PLIK_TTL` | `30d` | Gültigkeitsdauer: `30d`, `24h`, Sekunden |
|
|
||||||
|
|
||||||
Siehe [Plik-Upload](plik-upload.md) für die Einrichtung pro Job.
|
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.
|
||||||
|
|
||||||
## Alternative Konfigurationsdatei
|
## Umgebungsvariablen
|
||||||
|
|
||||||
Eine andere Konfigurationsdatei kann per `-c` übergeben werden:
|
| 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` |
|
||||||
|
|
||||||
```bash
|
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.
|
||||||
./stream-recorder.sh -c /pfad/zur/config.conf record mein-stream
|
|
||||||
|
Eigener Aufnahmepfad, z.B. auf einem separaten Laufwerk:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- /mnt/storage/recordings:/recordings
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach in den Einstellungen `download_path` auf `/recordings` setzen. Andere Pfade müssen zusätzlich in `RECORDING_ROOTS` eingetragen werden.
|
||||||
|
|
||||||
|
## Beispiel
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
recording:
|
||||||
|
download_path: /app/data/recordings
|
||||||
|
max_retries: 5
|
||||||
|
retry_delay: 5
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8484
|
||||||
|
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
username: admin
|
||||||
|
password: mein-passwort # wird beim Start in password_hash umgewandelt
|
||||||
|
|
||||||
|
schedule:
|
||||||
|
default_pre_buffer: 5
|
||||||
|
default_post_buffer: 5
|
||||||
|
|
||||||
|
ntfy:
|
||||||
|
url: https://ntfy.sh/stream-recorder
|
||||||
|
token: ""
|
||||||
|
events: start,stop,error,upload
|
||||||
|
|
||||||
|
plik:
|
||||||
|
url: https://plik.example.com
|
||||||
|
api_key: ""
|
||||||
|
ttl: 30d
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: INFO
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
# Plik-Upload
|
|
||||||
|
|
||||||
Fertige Aufnahmen können automatisch auf einen [Plik](https://github.com/root-gg/plik)-Server hochgeladen werden. Die Browser-URL zum Download wird im Log und optional per NTFY-Benachrichtigung ausgegeben.
|
|
||||||
|
|
||||||
## Einrichtung
|
|
||||||
|
|
||||||
### 1. Plik-Server konfigurieren
|
|
||||||
|
|
||||||
In `config/stream-recorder.conf`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PLIK_URL="https://plik.example.com"
|
|
||||||
PLIK_API_KEY="" # Optional, nur bei geschützten Servern
|
|
||||||
PLIK_TTL="30d" # Gültigkeitsdauer: 30d, 24h, oder Sekunden
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Pro Job aktivieren
|
|
||||||
|
|
||||||
In der Job-Datei:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PLIK_ENABLED=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Optional: Datei nach Upload löschen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DELETE_AFTER_UPLOAD=true
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verhalten
|
|
||||||
|
|
||||||
- Upload nach jeder regulär abgeschlossenen Aufnahme
|
|
||||||
- Upload nach manuellem Stop (`stop`-Befehl) — die Teilaufnahme wird hochgeladen
|
|
||||||
- Upload nach Verbindungsabbruch, wenn eine Teildatei vorhanden ist
|
|
||||||
- Browser-Link und Datei-URL werden ins Log geschrieben
|
|
||||||
- Optional wird der Link per NTFY gesendet (Event: `upload`)
|
|
||||||
|
|
||||||
## Mit Segment-Splitting
|
|
||||||
|
|
||||||
Bei aktiviertem [Segment-Splitting](segment-splitting.md) wird jedes Segment einzeln hochgeladen:
|
|
||||||
|
|
||||||
- Fertige Segmente werden während der Aufnahme hochgeladen (alle 60s geprüft)
|
|
||||||
- Das letzte Segment wird nach Ende der Aufnahme hochgeladen
|
|
||||||
- `DELETE_AFTER_UPLOAD=true` löscht jedes Segment nach dem Upload
|
|
||||||
|
|
||||||
## Manueller Upload
|
|
||||||
|
|
||||||
Eine vorhandene Datei kann auch manuell hochgeladen werden:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./stream-recorder.sh upload /pfad/zur/aufnahme.mp4 "Stream-Name"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Download-Link per Push
|
|
||||||
|
|
||||||
Um den Plik-Link automatisch als Push-Nachricht zu erhalten:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In config/stream-recorder.conf
|
|
||||||
NTFY_EVENTS="start,stop,error,upload"
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Plugins
|
||||||
|
|
||||||
|
Plugins erweitern den Recorder um zusätzliche Funktionen. Alle Plugins sind pro Job einzeln aktivierbar.
|
||||||
|
|
||||||
|
## NTFY-Benachrichtigungen
|
||||||
|
|
||||||
|
Push-Nachrichten per [ntfy.sh](https://ntfy.sh) bei verschiedenen Events.
|
||||||
|
|
||||||
|
### Einrichtung
|
||||||
|
|
||||||
|
1. **Einstellungen → NTFY URL**: Topic-URL eintragen (z.B. `https://ntfy.sh/mein-topic`)
|
||||||
|
2. **Einstellungen → Events**: Kommaseparierte Liste der gewünschten Events
|
||||||
|
3. **Pro Job**: Checkbox „Benachrichtigungen (NTFY)" aktivieren
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
| Event | Tag | Beschreibung |
|
||||||
|
|-------|-----|-------------|
|
||||||
|
| `start` | 🔴 | Aufnahme gestartet |
|
||||||
|
| `stop` | ✅ | Aufnahme beendet |
|
||||||
|
| `error` | 🚨 | Fehler oder Verbindungsabbruch |
|
||||||
|
| `upload` | 📤 | Plik-Upload abgeschlossen (mit Link) |
|
||||||
|
|
||||||
|
### Authentifizierung
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plik-Upload
|
||||||
|
|
||||||
|
Automatischer Upload von Aufnahmen auf einen [Plik](https://github.com/root-gg/plik)-Server nach Aufnahme-Ende.
|
||||||
|
|
||||||
|
### Einrichtung
|
||||||
|
|
||||||
|
1. **Einstellungen → Plik URL**: Server-URL eintragen
|
||||||
|
2. **Einstellungen → TTL**: Aufbewahrungsdauer (Standard: `30d`)
|
||||||
|
3. **Pro Job**: Checkbox „Plik-Upload" aktivieren
|
||||||
|
|
||||||
|
### Optionen pro Job
|
||||||
|
|
||||||
|
| Option | Beschreibung |
|
||||||
|
|--------|-------------|
|
||||||
|
| **Plik-Upload** | Upload nach Aufnahme-Ende aktivieren |
|
||||||
|
| **File nach Upload lokal löschen** | Lokale Datei nach erfolgreichem Upload entfernen |
|
||||||
|
|
||||||
|
### Links
|
||||||
|
|
||||||
|
Nach dem Upload werden zwei URLs generiert:
|
||||||
|
- **Browser-URL**: `https://plik.example.com/#/?id=UPLOAD_ID` — Übersichtsseite mit In-Browser-Wiedergabe
|
||||||
|
- **Download-URL**: Direkter Datei-Download
|
||||||
|
|
||||||
|
Die Plik-URL wird in der Datenbank gespeichert und im Dashboard bei den **Letzten Aufnahmen** als klickbarer Link angezeigt. Bei aktiviertem NTFY wird die Browser-URL zusätzlich in der Push-Nachricht mitgeschickt.
|
||||||
|
|
||||||
|
### Segment-Upload
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Segment-Splitting
|
|
||||||
|
|
||||||
Lange Aufnahmen (z.B. 8-Stunden-Streams) können automatisch in kleinere Dateien geteilt werden. Jedes Segment wird einzeln nach Plik hochgeladen, sobald es fertig ist.
|
|
||||||
|
|
||||||
## Einrichtung
|
|
||||||
|
|
||||||
In der Job-Datei `SEGMENT_DURATION` setzen:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Aufnahme alle 2 Stunden teilen
|
|
||||||
SEGMENT_DURATION="2h"
|
|
||||||
|
|
||||||
# Plik-Upload aktivieren (empfohlen)
|
|
||||||
PLIK_ENABLED=true
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dauerformate
|
|
||||||
|
|
||||||
| Format | Bedeutung |
|
|
||||||
|----------|------------------|
|
|
||||||
| `30m` | Alle 30 Minuten |
|
|
||||||
| `1h` | Stündlich |
|
|
||||||
| `2h` | Alle 2 Stunden |
|
|
||||||
| `3600` | 3600 Sekunden |
|
|
||||||
|
|
||||||
## So funktioniert's
|
|
||||||
|
|
||||||
1. ffmpeg teilt die Aufnahme automatisch in Segmente der angegebenen Länge
|
|
||||||
2. Ein Hintergrund-Prozess prüft alle 60 Sekunden auf fertige Segmente
|
|
||||||
3. Fertige Segmente werden sofort nach Plik hochgeladen (falls aktiviert)
|
|
||||||
4. Beim Beenden (regulär oder per `stop`) wird das letzte Segment noch hochgeladen
|
|
||||||
|
|
||||||
### Dateinamen
|
|
||||||
|
|
||||||
Segmente erhalten fortlaufende Nummern:
|
|
||||||
|
|
||||||
```
|
|
||||||
Mein-Stream_20260907_200000_seg000.mp4
|
|
||||||
Mein-Stream_20260907_200000_seg001.mp4
|
|
||||||
Mein-Stream_20260907_200000_seg002.mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
## Kombination mit Plik
|
|
||||||
|
|
||||||
Segment-Splitting ist besonders nützlich in Kombination mit Plik-Upload:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SEGMENT_DURATION="1h"
|
|
||||||
PLIK_ENABLED=true
|
|
||||||
DELETE_AFTER_UPLOAD=true # Lokale Segmente nach Upload löschen
|
|
||||||
```
|
|
||||||
|
|
||||||
So wird jede Stunde ein Segment hochgeladen und lokal gelöscht. Das spart Speicherplatz bei langen Aufnahmen.
|
|
||||||
|
|
||||||
## Kombination mit Zeitplanung
|
|
||||||
|
|
||||||
Segment-Splitting und Zeitplanung lassen sich kombinieren:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SCHEDULE_ENABLED=true
|
|
||||||
SCHEDULE_DAYS="*"
|
|
||||||
SCHEDULE_START="08:00"
|
|
||||||
SCHEDULE_STOP="20:00"
|
|
||||||
SEGMENT_DURATION="2h"
|
|
||||||
PLIK_ENABLED=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Die 12-Stunden-Aufnahme wird in 6 Segmente zu je 2 Stunden geteilt, jedes wird nach Fertigstellung hochgeladen.
|
|
||||||
|
|
||||||
## Verhalten bei Reconnect
|
|
||||||
|
|
||||||
Wenn die Verbindung abbricht und wiederhergestellt wird, werden die bisherigen Segmente hochgeladen und eine neue Segment-Serie mit neuem Zeitstempel begonnen.
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Mit `./stream-recorder.sh status` werden bei segmentierten Aufnahmen die Anzahl der Segmente und die Gesamtgröße angezeigt.
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Sender
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Die Senderbibliothek verwaltet häufig genutzte Streams zentral. Einmal angelegte Sender können in Jobs und Schnellaufnahmen per Dropdown ausgewählt werden — die Stream-URL und der Typ werden automatisch übernommen.
|
||||||
|
|
||||||
|
## Sender anlegen
|
||||||
|
|
||||||
|
Im Web-UI unter **Sender → + Neuer Sender**.
|
||||||
|
|
||||||
|
| Feld | Pflicht | Beschreibung |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| **Name** | Ja | Sendername (z.B. `WDR 2`, `DLF Kultur`) |
|
||||||
|
| **Typ** | Nein | Stream-Typ: `auto` (Standard), RTMP, HLS, MP3, AAC, OGG, HTTP |
|
||||||
|
| **Stream-URL** | Ja | URL des Streams (z.B. `https://wdr-wdr2-live.icecast.wdr.de/wdr/wdr2/live/mp3/256/stream.mp3`) |
|
||||||
|
| **Beschreibung** | Nein | Freitext-Notiz (z.B. `Hauptstream, 256 kbit/s`) |
|
||||||
|
|
||||||
|
## Sender bearbeiten & löschen
|
||||||
|
|
||||||
|
In der Sender-Übersicht stehen pro Eintrag die Buttons **Bearbeiten** und **Löschen** zur Verfügung. Das Löschen eines Senders hat keinen Einfluss auf bereits existierende Jobs — die Stream-URL bleibt im Job erhalten.
|
||||||
|
|
||||||
|
## Verwendung in Jobs
|
||||||
|
|
||||||
|
Beim Erstellen oder Bearbeiten eines Jobs kann über das Dropdown **Senderbibliothek** ein gespeicherter Sender ausgewählt werden. Dabei werden automatisch übernommen:
|
||||||
|
|
||||||
|
- **Stream-URL** und **Typ** — die URL-Eingabe wird ausgeblendet
|
||||||
|
- **Name** — falls das Namensfeld noch leer ist
|
||||||
|
- **Beschreibung** — falls das Beschreibungsfeld noch leer ist
|
||||||
|
|
||||||
|
Alternativ bleibt die Option **URL manuell eingeben** bestehen.
|
||||||
|
|
||||||
|
## Verwendung in Schnellaufnahmen
|
||||||
|
|
||||||
|
Auch bei Schnellaufnahmen (Dashboard → + Schnellaufnahme) steht die Senderbibliothek als Dropdown zur Verfügung. Bei Auswahl werden URL, Typ und Name übernommen.
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# Zeitplanung
|
|
||||||
|
|
||||||
Die Zeitplanung wird direkt in der Job-Datei konfiguriert — kein Cron-Wissen nötig. Ein systemd-Timer prüft jede Minute alle Jobs und startet/stoppt Aufnahmen automatisch.
|
|
||||||
|
|
||||||
## Einrichtung
|
|
||||||
|
|
||||||
1. **Job-Datei erstellen** und `SCHEDULE_*`-Parameter setzen
|
|
||||||
2. **Scheduler installieren** mit `sudo install/install.sh` (einmalig)
|
|
||||||
3. Fertig — der Timer prüft jede Minute alle Jobs
|
|
||||||
|
|
||||||
## Zeitplan-Parameter
|
|
||||||
|
|
||||||
| Parameter | Standard | Beschreibung |
|
|
||||||
|--------------------|----------|-------------------------------------------------|
|
|
||||||
| `SCHEDULE_ENABLED` | `false` | Zeitplan aktivieren (`true`/`false`) |
|
|
||||||
| `SCHEDULE_ONCE` | `false` | Einmaliger Termin statt wöchentlichem Zeitplan |
|
|
||||||
| `SCHEDULE_DATE` | (leer) | Datum für einmalige Termine (`YYYY-MM-DD`) |
|
|
||||||
| `SCHEDULE_DAYS` | `*` | Aufnahmetage für wöchentliche Zeitpläne |
|
|
||||||
| `SCHEDULE_START` | (leer) | Startzeit im Format `HH:MM` |
|
|
||||||
| `SCHEDULE_STOP` | (leer) | Endzeit im Format `HH:MM` (leer = kein Auto-Stop)|
|
|
||||||
|
|
||||||
## Tagesformate
|
|
||||||
|
|
||||||
| Format | Bedeutung |
|
|
||||||
|--------------|----------------------------|
|
|
||||||
| `*` | Täglich |
|
|
||||||
| `Mo-Fr` | Montag bis Freitag |
|
|
||||||
| `Sa,So` | Samstag und Sonntag |
|
|
||||||
| `Mo,Mi,Fr` | Einzelne Tage |
|
|
||||||
| `Mo-Fr,So` | Kombiniert |
|
|
||||||
|
|
||||||
Die Tagesangaben funktionieren auf Deutsch (Mo, Di, Mi, Do, Fr, Sa, So) und Englisch (Mon, Tue, Wed, Thu, Fri, Sat, Sun).
|
|
||||||
|
|
||||||
## Verhalten
|
|
||||||
|
|
||||||
- **Start + Stop gesetzt:** Aufnahme läuft im angegebenen Zeitfenster
|
|
||||||
- **Nur Start gesetzt:** Aufnahme startet zur angegebenen Zeit, läuft bis `MAX_DURATION` oder manueller Stop
|
|
||||||
- **Einmalig:** Mit `SCHEDULE_ONCE=true` und `SCHEDULE_DATE="YYYY-MM-DD"` startet der Job nur an diesem Datum
|
|
||||||
- **Manuell gestartete Aufnahmen** werden vom Scheduler nicht beeinflusst
|
|
||||||
- **Nach Neustart** des Servers erkennt der Scheduler automatisch, welche Aufnahmen laufen sollten
|
|
||||||
- **Nachtübergreifend:** Start- und Stoppzeiten können über Mitternacht gehen (z.B. `23:00` bis `02:00`)
|
|
||||||
|
|
||||||
## Beispiele
|
|
||||||
|
|
||||||
### Tägliche Aufnahme von 20:00 bis 22:00
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SCHEDULE_ENABLED=true
|
|
||||||
SCHEDULE_DAYS="*"
|
|
||||||
SCHEDULE_START="20:00"
|
|
||||||
SCHEDULE_STOP="22:00"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Werktags um 18:00, 1 Stunde
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SCHEDULE_ENABLED=true
|
|
||||||
SCHEDULE_DAYS="Mo-Fr"
|
|
||||||
SCHEDULE_START="18:00"
|
|
||||||
MAX_DURATION="1h"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Nur am Wochenende
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SCHEDULE_ENABLED=true
|
|
||||||
SCHEDULE_DAYS="Sa,So"
|
|
||||||
SCHEDULE_START="14:00"
|
|
||||||
SCHEDULE_STOP="18:00"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Einmalige Aufnahme am 15.09.2026
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SCHEDULE_ENABLED=true
|
|
||||||
SCHEDULE_ONCE=true
|
|
||||||
SCHEDULE_DATE="2026-09-15"
|
|
||||||
SCHEDULE_START="21:00"
|
|
||||||
MAX_DURATION="30m"
|
|
||||||
```
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
###############################################
|
|
||||||
# stream-recorder - Installations-Script
|
|
||||||
# Richtet den systemd-Timer für die
|
|
||||||
# automatische Zeitplanung ein.
|
|
||||||
###############################################
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
SERVICE_NAME="stream-recorder"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "stream-recorder - Installation"
|
|
||||||
echo "=============================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Root-Prüfung
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
|
||||||
echo "FEHLER: Dieses Script muss als root ausgeführt werden."
|
|
||||||
echo " sudo $0"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Prüfe ob systemd vorhanden
|
|
||||||
if ! command -v systemctl &>/dev/null; then
|
|
||||||
echo "FEHLER: systemd ist nicht verfügbar."
|
|
||||||
echo " Dieses Script benötigt systemd für die Zeitplanung."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Prüfe Abhängigkeiten
|
|
||||||
if ! command -v ffmpeg &>/dev/null; then
|
|
||||||
echo "ffmpeg nicht gefunden. Installiere ffmpeg..."
|
|
||||||
apt-get update -qq && apt-get install -y -qq ffmpeg
|
|
||||||
echo "ffmpeg installiert."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Hauptscript ausführbar machen
|
|
||||||
chmod +x "${SCRIPT_DIR}/stream-recorder.sh"
|
|
||||||
|
|
||||||
# Konfiguration aus Vorlage erstellen, falls nicht vorhanden
|
|
||||||
if [[ ! -f "${SCRIPT_DIR}/config/stream-recorder.conf" ]]; then
|
|
||||||
if [[ -f "${SCRIPT_DIR}/config/stream-recorder.conf.dist" ]]; then
|
|
||||||
cp "${SCRIPT_DIR}/config/stream-recorder.conf.dist" "${SCRIPT_DIR}/config/stream-recorder.conf"
|
|
||||||
echo "Konfiguration erstellt: config/stream-recorder.conf"
|
|
||||||
echo " Bitte anpassen: nano ${SCRIPT_DIR}/config/stream-recorder.conf"
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Installationspfad: ${SCRIPT_DIR}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# systemd Service erstellen
|
|
||||||
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=stream-recorder Scheduler
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=${SCRIPT_DIR}/stream-recorder.sh scheduler
|
|
||||||
KillMode=none
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# systemd Timer erstellen
|
|
||||||
cat > "/etc/systemd/system/${SERVICE_NAME}.timer" <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=stream-recorder Scheduler Timer
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnBootSec=30
|
|
||||||
OnCalendar=*-*-* *:*:00
|
|
||||||
AccuracySec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# systemd neu laden und Timer aktivieren
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable "${SERVICE_NAME}.timer"
|
|
||||||
systemctl start "${SERVICE_NAME}.timer"
|
|
||||||
|
|
||||||
echo "Installation abgeschlossen!"
|
|
||||||
echo ""
|
|
||||||
echo "Status:"
|
|
||||||
systemctl status "${SERVICE_NAME}.timer" --no-pager 2>/dev/null || true
|
|
||||||
echo ""
|
|
||||||
echo "Der Scheduler prüft jetzt jede Minute die Job-Dateien"
|
|
||||||
echo "und startet/stoppt Aufnahmen automatisch."
|
|
||||||
echo ""
|
|
||||||
echo "Nächste Schritte:"
|
|
||||||
echo " 1. Konfiguration anpassen: nano ${SCRIPT_DIR}/config/stream-recorder.conf"
|
|
||||||
echo " 2. Job-Datei erstellen: cp jobs/templates/example-rtmp.job.dist jobs/mein-stream.job"
|
|
||||||
echo " 3. Job-Datei bearbeiten: nano jobs/mein-stream.job"
|
|
||||||
echo " 4. SCHEDULE_ENABLED=true setzen und Zeiten konfigurieren"
|
|
||||||
echo " 5. Jobs prüfen: ./stream-recorder.sh list"
|
|
||||||
echo ""
|
|
||||||
echo "Deinstallation: sudo install/uninstall.sh"
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
###############################################
|
|
||||||
# stream-recorder - Deinstallations-Script
|
|
||||||
# Entfernt den systemd-Timer und stoppt
|
|
||||||
# alle laufenden Aufnahmen.
|
|
||||||
###############################################
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
SERVICE_NAME="stream-recorder"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "stream-recorder - Deinstallation"
|
|
||||||
echo "================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Root-Prüfung
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
|
||||||
echo "FEHLER: Dieses Script muss als root ausgeführt werden."
|
|
||||||
echo " sudo $0"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Timer stoppen und deaktivieren
|
|
||||||
if systemctl is-active --quiet "${SERVICE_NAME}.timer" 2>/dev/null; then
|
|
||||||
echo "Stoppe Timer..."
|
|
||||||
systemctl stop "${SERVICE_NAME}.timer"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if systemctl is-enabled --quiet "${SERVICE_NAME}.timer" 2>/dev/null; then
|
|
||||||
echo "Deaktiviere Timer..."
|
|
||||||
systemctl disable "${SERVICE_NAME}.timer"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Service-Dateien entfernen
|
|
||||||
if [[ -f "/etc/systemd/system/${SERVICE_NAME}.timer" ]]; then
|
|
||||||
rm -f "/etc/systemd/system/${SERVICE_NAME}.timer"
|
|
||||||
echo "Timer-Datei entfernt."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]]; then
|
|
||||||
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
|
|
||||||
echo "Service-Datei entfernt."
|
|
||||||
fi
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
|
|
||||||
# Laufende Aufnahmen stoppen
|
|
||||||
echo ""
|
|
||||||
read -rp "Alle laufenden Aufnahmen stoppen? [j/N] " stop_all
|
|
||||||
if [[ "$stop_all" =~ ^[Jj]$ ]]; then
|
|
||||||
"${SCRIPT_DIR}/stream-recorder.sh" stop-all 2>/dev/null || true
|
|
||||||
echo "Aufnahmen gestoppt."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Deinstallation abgeschlossen."
|
|
||||||
echo ""
|
|
||||||
echo "Hinweis: Die Job-Dateien, Konfiguration und Aufnahmen"
|
|
||||||
echo " wurden NICHT entfernt."
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
###############################################
|
|
||||||
# stream-recorder - Job-Definition (HLS/m3u8)
|
|
||||||
###############################################
|
|
||||||
# Kopieren und anpassen:
|
|
||||||
# cp jobs/templates/example-hls.job.dist jobs/mein-hls-stream.job
|
|
||||||
|
|
||||||
# Name des Streams
|
|
||||||
STREAM_NAME="Mein-HLS-Stream"
|
|
||||||
|
|
||||||
# Stream-URL (m3u8-Playlist)
|
|
||||||
STREAM_URL="https://example.com/live/playlist.m3u8"
|
|
||||||
|
|
||||||
# Stream-Typ
|
|
||||||
STREAM_TYPE="hls"
|
|
||||||
|
|
||||||
# Ausgabeformat (mp4 oder mkv empfohlen)
|
|
||||||
OUTPUT_FORMAT="mp4"
|
|
||||||
|
|
||||||
# Maximale Aufnahmedauer (leer = unbegrenzt, z.B. 5m oder 1h)
|
|
||||||
MAX_DURATION=""
|
|
||||||
|
|
||||||
# Zusätzliche ffmpeg-Argumente (optional)
|
|
||||||
EXTRA_FFMPEG_ARGS=""
|
|
||||||
|
|
||||||
###############################################
|
|
||||||
# Segment-Splitting (optional)
|
|
||||||
# z.B. 1h = stündlich, 2h = alle 2 Stunden
|
|
||||||
###############################################
|
|
||||||
|
|
||||||
SEGMENT_DURATION=""
|
|
||||||
|
|
||||||
###############################################
|
|
||||||
# Zeitplanung (optional)
|
|
||||||
###############################################
|
|
||||||
|
|
||||||
SCHEDULE_ENABLED=false
|
|
||||||
SCHEDULE_ONCE=false
|
|
||||||
SCHEDULE_DATE=""
|
|
||||||
SCHEDULE_DAYS="*"
|
|
||||||
SCHEDULE_START="20:00"
|
|
||||||
SCHEDULE_STOP="22:00"
|
|
||||||
|
|
||||||
###############################################
|
|
||||||
# Plik-Upload (optional)
|
|
||||||
# Fertige Aufnahme automatisch auf Plik hochladen
|
|
||||||
# PLIK_URL muss in der Konfiguration gesetzt sein
|
|
||||||
###############################################
|
|
||||||
|
|
||||||
PLIK_ENABLED=false
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
###############################################
|
|
||||||
# stream-recorder - Job-Definition (MP3/Audio)
|
|
||||||
###############################################
|
|
||||||
# Kopieren und anpassen:
|
|
||||||
# cp jobs/templates/example-mp3.job.dist jobs/mein-radio.job
|
|
||||||
|
|
||||||
# Name des Streams
|
|
||||||
STREAM_NAME="Mein-Radiosender"
|
|
||||||
|
|
||||||
# Stream-URL (HTTP/HTTPS Audio-Stream)
|
|
||||||
STREAM_URL="https://example.com/stream.mp3"
|
|
||||||
|
|
||||||
# Stream-Typ: auto, rtmp, hls, mp3, aac, ogg, flac, http
|
|
||||||
STREAM_TYPE="auto"
|
|
||||||
|
|
||||||
# Ausgabeformat
|
|
||||||
OUTPUT_FORMAT=""
|
|
||||||
|
|
||||||
# Maximale Aufnahmedauer (leer = unbegrenzt)
|
|
||||||
# Für Radio-Mitschnitte empfohlen, z.B. 1h für 1 Stunde
|
|
||||||
MAX_DURATION="1h"
|
|
||||||
|
|
||||||
# Zusätzliche ffmpeg-Argumente (optional)
|
|
||||||
EXTRA_FFMPEG_ARGS=""
|
|
||||||
|
|
||||||
###############################################
|
|
||||||
# Segment-Splitting (optional)
|
|
||||||
# z.B. 1h = stündlich, 2h = alle 2 Stunden
|
|
||||||
###############################################
|
|
||||||
|
|
||||||
SEGMENT_DURATION=""
|
|
||||||
|
|
||||||
###############################################
|
|
||||||
# Zeitplanung (optional)
|
|
||||||
###############################################
|
|
||||||
|
|
||||||
SCHEDULE_ENABLED=false
|
|
||||||
SCHEDULE_ONCE=false
|
|
||||||
SCHEDULE_DATE=""
|
|
||||||
SCHEDULE_DAYS="Mo-Fr"
|
|
||||||
SCHEDULE_START="18:00"
|
|
||||||
SCHEDULE_STOP=""
|
|
||||||
|
|
||||||
###############################################
|
|
||||||
# Plik-Upload (optional)
|
|
||||||
# Fertige Aufnahme automatisch auf Plik hochladen
|
|
||||||
# PLIK_URL muss in der Konfiguration gesetzt sein
|
|
||||||
###############################################
|
|
||||||
|
|
||||||
PLIK_ENABLED=false
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
###############################################
|
|
||||||
# stream-recorder - Job-Definition (RTMP)
|
|
||||||
###############################################
|
|
||||||
# Kopieren und anpassen:
|
|
||||||
# cp jobs/templates/example-rtmp.job.dist jobs/mein-stream.job
|
|
||||||
|
|
||||||
# Name des Streams (wird für Dateinamen und Verzeichnisse verwendet)
|
|
||||||
STREAM_NAME="Mein-RTMP-Stream"
|
|
||||||
|
|
||||||
# Stream-URL
|
|
||||||
STREAM_URL="rtmp://example.com/live/stream-key"
|
|
||||||
|
|
||||||
# Stream-Typ: auto, rtmp, hls, mp3, aac, ogg, flac, http
|
|
||||||
# Bei "auto" wird der Typ anhand der URL erkannt
|
|
||||||
STREAM_TYPE="auto"
|
|
||||||
|
|
||||||
# Ausgabeformat (Dateiendung): mp4, mkv, mp3, aac, ogg, flac
|
|
||||||
# Leer lassen für automatische Erkennung basierend auf Stream-Typ
|
|
||||||
OUTPUT_FORMAT=""
|
|
||||||
|
|
||||||
# Maximale Aufnahmedauer (leer = unbegrenzt)
|
|
||||||
# Beispiele: 300 = 5 Minuten, 5m = 5 Minuten, 1h30m = 90 Minuten
|
|
||||||
MAX_DURATION=""
|
|
||||||
|
|
||||||
# Zusätzliche ffmpeg-Argumente (optional)
|
|
||||||
EXTRA_FFMPEG_ARGS=""
|
|
||||||
|
|
||||||
###############################################
|
|
||||||
# Segment-Splitting (optional)
|
|
||||||
# Aufnahme in Teile zerlegen
|
|
||||||
# Jedes Segment wird einzeln nach Plik hochgeladen
|
|
||||||
# Beispiele: 1h = stündlich, 2h = alle 2 Stunden, 30m = alle 30 Minuten
|
|
||||||
# Leer = deaktiviert (eine durchgehende Datei)
|
|
||||||
###############################################
|
|
||||||
|
|
||||||
SEGMENT_DURATION=""
|
|
||||||
|
|
||||||
###############################################
|
|
||||||
# Zeitplanung (optional)
|
|
||||||
# Damit der Scheduler funktioniert, muss er
|
|
||||||
# installiert sein: sudo install/install.sh
|
|
||||||
###############################################
|
|
||||||
|
|
||||||
# Zeitplan aktivieren (true/false)
|
|
||||||
SCHEDULE_ENABLED=false
|
|
||||||
|
|
||||||
# Einmalige Aufnahme statt wöchentlichem Zeitplan (true/false)
|
|
||||||
SCHEDULE_ONCE=false
|
|
||||||
|
|
||||||
# Datum für einmalige Aufnahme (YYYY-MM-DD)
|
|
||||||
# Nur relevant, wenn SCHEDULE_ONCE=true ist
|
|
||||||
SCHEDULE_DATE=""
|
|
||||||
|
|
||||||
# Aufnahmetage für wöchentliche Zeitpläne
|
|
||||||
# * = täglich
|
|
||||||
# Mo-Fr = Montag bis Freitag
|
|
||||||
# Sa,So = Samstag und Sonntag
|
|
||||||
# Mo,Mi,Fr = einzelne Tage
|
|
||||||
SCHEDULE_DAYS="*"
|
|
||||||
|
|
||||||
# Startzeit (HH:MM)
|
|
||||||
SCHEDULE_START="20:00"
|
|
||||||
|
|
||||||
# Endzeit (HH:MM)
|
|
||||||
# Leer = Aufnahme läuft bis MAX_DURATION oder manueller Stop
|
|
||||||
SCHEDULE_STOP="22:00"
|
|
||||||
|
|
||||||
###############################################
|
|
||||||
# Plik-Upload (optional)
|
|
||||||
# Fertige Aufnahme automatisch auf Plik hochladen
|
|
||||||
# PLIK_URL muss in der Konfiguration gesetzt sein
|
|
||||||
###############################################
|
|
||||||
|
|
||||||
PLIK_ENABLED=false
|
|
||||||