4 Commits
Author SHA1 Message Date
Patrick AsmusandClaude Opus 4.6 1ad87c7186 Versionstags ohne v-Prefix aus Release-Workflow und README entfernt
Build Dev Docker Image / build-amd64 (push) Successful in 7s
Build Dev Docker Image / build-arm64 (push) Successful in 10s
Build Dev Docker Image / publish-dev-manifest (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-16 09:54:13 +02:00
Patrick AsmusandClaude Opus 4.6 b446ce9b3e v3.0.0: Docker-Migration mit CI-Pipeline und neuen Features
Build Release Docker Image / build-arm64 (release) Successful in 1m0s
Build Release Docker Image / build-amd64 (release) Successful in 2m50s
Build Release Docker Image / publish-release-manifest (release) Successful in 7s
- Gitea CI-Workflows fuer Multi-Arch Docker Builds (amd64 + arm64)
- Beschreibungsfeld und mehrtaegige Zeitplanung fuer Jobs
- Benachrichtigungen auf Deutsch
- docker-compose nutzt Registry-Image

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-16 09:40:40 +02:00
Patrick Asmus 9fa589789e erster docker release 2026-09-15 18:10:57 +02:00
Patrick Asmus 794156a088 erster docker release 2026-09-15 18:10:34 +02:00
37 changed files with 3399 additions and 2786 deletions
+76
View File
@@ -0,0 +1,76 @@
name: Build Dev Docker Image
on:
workflow_dispatch:
push:
branches: [main, 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: |
docker build --pull --platform linux/amd64 \
-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: |
docker build --pull --platform linux/arm64 \
-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}"
+73
View File
@@ -0,0 +1,73 @@
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}"
docker build --pull --platform linux/amd64 \
-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}"
docker build --pull --platform linux/arm64 \
-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"
+7 -5
View File
@@ -1,6 +1,8 @@
# Benutzerspezifische Konfiguration (aus .dist erstellt)
config/stream-recorder.conf
# Persistente Daten (DB, Config, Recordings)
data/
# Job-Dateien (nur Vorlagen werden versioniert)
jobs/*.job
!jobs/templates/
# Python
__pycache__/
*.pyc
*.pyo
.venv/
+27
View File
@@ -0,0 +1,27 @@
FROM python:3.12-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg curl && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY bin/backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY bin/backend/ /app/
COPY bin/frontend/ /app/frontend/
COPY config/config.yml.dist /app/config.yml.dist
RUN mkdir -p /app/data/recordings
ENV DATA_DIR=/app/data
ENV STATIC_DIR=/app/frontend/static
ENV PYTHONUNBUFFERED=1
EXPOSE 8484
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8484/api/health || exit 1
CMD ["python", "app.py"]
+66 -66
View File
@@ -7,7 +7,7 @@
<h1 align="center">stream-recorder</h1>
<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>
<h6 align="center">
@@ -23,88 +23,88 @@
## 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
- **Job-basiert** — Ein Stream = eine Job-Datei, inklusive Zeitplanung
- **Segment-Splitting** — Lange Aufnahmen automatisch in Teile zerlegen (z.B. stündlich)
- **Plik-Upload** — Aufnahmen und Segmente automatisch auf Plik hochladen
- **Zeitplanung** — Aufnahmezeiten direkt in der Job-Datei konfigurieren (kein Cron nötig)
- **Aufnahmen verlängern** — Laufende Aufnahmen spontan verlängern oder auf unbegrenzt setzen
- **Zeitplanung** — Automatische Aufnahmezeiten direkt im Web-UI konfigurieren
- **Segment-Splitting** — Lange Aufnahmen automatisch in Teile zerlegen
- **Auto-Reconnect** — Automatische Wiederverbindung bei Verbindungsabbrüchen
- **Live-Status** — Laufende Aufnahmen mit Laufzeit, Dateigröße und Segment-Anzahl anzeigen
- **NTFY-Benachrichtigungen** — Push-Nachrichten bei Start, Stop, Fehler und Upload
- **Schnellaufnahme** — Interaktiver Modus für spontane Aufnahmen
## Voraussetzungen
- **Linux** (getestet unter Ubuntu/Debian)
- **Bash** 4.0+
- **ffmpeg** (`sudo apt install ffmpeg`)
- **curl** (optional, für NTFY und Plik)
- **Schnellaufnahme** — URL eingeben, sofort aufnehmen
- **NTFY-Benachrichtigungen** — Push-Nachrichten bei Start, Stop, Fehler und Upload (pro Job schaltbar)
- **Plik-Upload** — Aufnahmen automatisch hochladen, optional danach löschen (pro Job schaltbar)
- **Metadata-Monitor** — Automatische Show-Erkennung via Stream-Metadaten (Plugin)
- **REST-API** — Alle Funktionen auch per API steuerbar
## Schnellstart
```bash
# Klonen und einrichten
git clone https://git.techniverse.net/scriptos/stream-recorder.git /opt/stream-recorder
cd /opt/stream-recorder
cp config/stream-recorder.conf.dist config/stream-recorder.conf
nano config/stream-recorder.conf
# Repository klonen
git clone https://git.techniverse.net/scriptos/stream-recorder.git
cd stream-recorder
# Scheduler installieren (optional)
sudo install/install.sh
# Ersten Job erstellen
./stream-recorder.sh create
# Oder direkt loslegen
./stream-recorder.sh quick
# Container starten
docker compose up -d
```
## Befehle
Das Web-UI ist unter `http://localhost:8484` erreichbar. Die Konfiguration wird beim ersten Start automatisch in `data/config.yml` erstellt.
## Container Image
Release-Images werden automatisch in der Gitea Container Registry veröffentlicht:
```bash
./stream-recorder.sh record [job] # Aufnahme starten (ohne Angabe: Auswahl)
./stream-recorder.sh stop [job] # Aufnahme stoppen (ohne Angabe: Auswahl)
./stream-recorder.sh status # Laufende Aufnahmen anzeigen
./stream-recorder.sh list # Alle Jobs anzeigen
./stream-recorder.sh create # Neuen Job erstellen
./stream-recorder.sh quick # Schnellaufnahme
./stream-recorder.sh logs # Logs live verfolgen
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:
- "8484:8484"
volumes:
- ./data:/app/data
environment:
TZ: Europe/Berlin
```
## Daten
Alle persistenten Daten liegen in einem einzigen Volume (`./data`):
| Pfad | Beschreibung |
|------|-------------|
| `data/config.yml` | Konfiguration (wird beim ersten Start automatisch erstellt) |
| `data/stream-recorder.db` | SQLite-Datenbank |
| `data/recordings/` | Aufnahmen (pro Job ein Unterordner) |
## Multi-Arch Build
```bash
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t stream-recorder:latest .
```
## Dokumentation
| Thema | Beschreibung |
|-------|-------------|
| [Installation](docs/installation.md) | Installation, Updates, Deinstallation |
| [Konfiguration](docs/konfiguration.md) | Alle Konfigurationsparameter |
| [Job-Dateien](docs/jobs.md) | Jobs erstellen und verwalten |
| [Zeitplanung](docs/zeitplanung.md) | Automatische Aufnahmezeiten |
| [Segment-Splitting](docs/segment-splitting.md) | Aufnahmen in Teile zerlegen |
| [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
```
| [Jobs](docs/jobs.md) | Jobs erstellen, Zeitplanung, Schnellaufnahme |
| [Aufnahmen](docs/aufnahmen.md) | Verlängern, Stoppen, Reconnect, Segment-Splitting |
| [Plugins](docs/plugins.md) | NTFY, Plik-Upload, Metadata-Monitor |
| [Konfiguration](docs/konfiguration.md) | config.yml, alle Parameter |
| [API](docs/api.md) | REST-API Endpunkte |
<br><br>
<p align="center">
+392
View File
@@ -0,0 +1,392 @@
import base64
import contextlib
import datetime
import os
import secrets
import sys
from fastapi import FastAPI, HTTPException, Request, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import Optional
from config import Config
from models import init_db, get_session, Job, Recording as RecordingModel, LogEntry
from recorder import RecordingManager, format_elapsed
from scheduler import StreamScheduler
DATA_DIR = os.environ.get("DATA_DIR", "/app/data")
STATIC_DIR = os.environ.get("STATIC_DIR", "/app/frontend/static")
os.makedirs(DATA_DIR, exist_ok=True)
config = Config(os.path.join(DATA_DIR, "config.yml"))
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.start()
yield
scheduler.stop()
app = FastAPI(title="stream-recorder", version="3.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
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.startswith("/api/health"):
return await call_next(request)
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Basic "):
try:
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
username, password = decoded.split(":", 1)
expected_user = config.get("auth", "username", default="admin")
expected_pass = config.get("auth", "password", default="stream-recorder")
if secrets.compare_digest(username, expected_user) and secrets.compare_digest(password, expected_pass):
return await call_next(request)
except Exception:
pass
return JSONResponse(
status_code=401,
content={"detail": "Unauthorized"},
headers={"WWW-Authenticate": 'Basic realm="stream-recorder"'},
)
# --- Health ---
@app.get("/api/health")
async def health():
return {"status": "ok", "version": "3.0.0"}
# --- Jobs CRUD ---
class JobCreate(BaseModel):
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
schedule_enabled: bool = False
schedule_once: bool = False
schedule_date: str = ""
schedule_days: str = "*"
schedule_start: str = ""
schedule_stop: str = ""
schedule_end_date: str = ""
description: str = ""
ntfy_enabled: bool = False
plik_enabled: bool = False
delete_after_upload: bool = False
metadata_monitor_enabled: bool = False
metadata_pattern: str = ""
metadata_grace_period: int = 300
metadata_poll_interval: int = 30
class JobUpdate(JobCreate):
pass
@app.get("/api/jobs")
async def list_jobs():
session = get_session()
try:
jobs = session.query(Job).order_by(Job.id).all()
result = []
for j in jobs:
is_recording = session.query(
session.query(RecordingModel).filter_by(job_id=j.id, status="running").exists()
).scalar()
result.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_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,
"description": j.description,
"ntfy_enabled": j.ntfy_enabled,
"plik_enabled": j.plik_enabled,
"delete_after_upload": j.delete_after_upload,
"metadata_monitor_enabled": j.metadata_monitor_enabled,
"metadata_pattern": j.metadata_pattern,
"metadata_grace_period": j.metadata_grace_period,
"metadata_poll_interval": j.metadata_poll_interval,
"is_recording": is_recording,
"created_at": j.created_at.isoformat() if j.created_at else None,
})
return result
finally:
session.close()
@app.post("/api/jobs")
async def create_job(data: JobCreate):
session = get_session()
try:
job = Job(**data.model_dump())
session.add(job)
session.commit()
return {"id": job.id, "message": "Job erstellt"}
finally:
session.close()
@app.get("/api/jobs/{job_id}")
async 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 {
"id": job.id, "name": job.name, "stream_url": job.stream_url,
"stream_type": job.stream_type, "output_format": job.output_format,
"max_duration": job.max_duration, "extra_ffmpeg_args": job.extra_ffmpeg_args,
"segment_duration": job.segment_duration,
"schedule_enabled": job.schedule_enabled, "schedule_once": job.schedule_once,
"schedule_date": job.schedule_date, "schedule_days": job.schedule_days,
"schedule_start": job.schedule_start, "schedule_stop": job.schedule_stop,
"schedule_end_date": job.schedule_end_date, "description": job.description,
"ntfy_enabled": job.ntfy_enabled,
"plik_enabled": job.plik_enabled, "delete_after_upload": job.delete_after_upload,
"metadata_monitor_enabled": job.metadata_monitor_enabled,
"metadata_pattern": job.metadata_pattern,
"metadata_grace_period": job.metadata_grace_period,
"metadata_poll_interval": job.metadata_poll_interval,
}
finally:
session.close()
@app.put("/api/jobs/{job_id}")
async 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")
for k, v in data.model_dump().items():
setattr(job, k, v)
job.updated_at = datetime.datetime.utcnow()
session.commit()
return {"message": "Job aktualisiert"}
finally:
session.close()
@app.delete("/api/jobs/{job_id}")
async 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")
session.delete(job)
session.commit()
return {"message": "Job gelöscht"}
finally:
session.close()
@app.post("/api/jobs/{job_id}/record")
async def start_job_recording(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")
duration = job.max_duration
if not duration and job.schedule_start and job.schedule_stop:
if job.schedule_end_date and job.schedule_date:
start_dt = datetime.datetime.strptime(f"{job.schedule_date} {job.schedule_start}", "%Y-%m-%d %H:%M")
end_dt = datetime.datetime.strptime(f"{job.schedule_end_date} {job.schedule_stop}", "%Y-%m-%d %H:%M")
duration = max(0, int((end_dt - start_dt).total_seconds()))
else:
def _time_to_minutes(t: str) -> int:
parts = t.split(":")
return int(parts[0]) * 60 + int(parts[1])
start_min = _time_to_minutes(job.schedule_start)
stop_min = _time_to_minutes(job.schedule_stop)
if stop_min > start_min:
duration = (stop_min - start_min) * 60
else:
duration = (1440 - start_min + stop_min) * 60
job_data = {
"name": job.name, "stream_url": job.stream_url,
"stream_type": job.stream_type, "output_format": job.output_format,
"max_duration": duration, "extra_ffmpeg_args": job.extra_ffmpeg_args,
"segment_duration": job.segment_duration,
"description": job.description,
"metadata_monitor_enabled": job.metadata_monitor_enabled,
"metadata_pattern": job.metadata_pattern,
"metadata_grace_period": job.metadata_grace_period or 300,
"metadata_poll_interval": job.metadata_poll_interval or 30,
"ntfy_enabled": job.ntfy_enabled,
"plik_enabled": job.plik_enabled,
"delete_after_upload": job.delete_after_upload,
}
finally:
session.close()
result = recorder.start_recording(job_id=job_id, **job_data)
return result
# --- Quick Recording ---
class QuickRecordRequest(BaseModel):
url: str
name: str = ""
stream_type: str = "auto"
max_duration: Optional[int] = None
@app.post("/api/quick-record")
async def quick_record(data: QuickRecordRequest):
name = data.name or f"Schnellaufnahme_{datetime.datetime.now().strftime('%H%M%S')}"
result = recorder.start_recording(
job_id=None,
name=name,
stream_url=data.url,
stream_type=data.stream_type,
max_duration=data.max_duration,
)
return result
# --- Recordings ---
@app.get("/api/recordings")
async def list_recordings(active_only: bool = False, limit: int = 50):
if active_only:
return recorder.get_all_active()
return recorder.get_all_recordings(limit=limit)
@app.get("/api/recordings/{rec_id}")
async 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")
async def stop_recording(rec_id: int):
recorder.stop_recording(rec_id)
return {"message": "Aufnahme gestoppt"}
@app.post("/api/recordings/{rec_id}/extend")
async def extend_recording(rec_id: int, minutes: int = Query(default=30)):
ok = recorder.extend_recording(rec_id, minutes * 60)
if not 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")
async 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")
async def get_logs(limit: int = 100, level: str = ""):
session = get_session()
try:
q = session.query(LogEntry).order_by(LogEntry.id.desc())
if level:
q = q.filter(LogEntry.level == level.upper())
logs = q.limit(limit).all()
return [
{
"id": l.id,
"timestamp": l.timestamp.isoformat() if l.timestamp else None,
"level": l.level,
"message": l.message,
"job_name": l.job_name,
}
for l in logs
]
finally:
session.close()
# --- Config ---
@app.get("/api/config")
async def get_config():
safe_config = {**config.data}
if "auth" in safe_config:
safe_auth = {**safe_config["auth"]}
if safe_auth.get("password"):
safe_auth["password"] = "***"
safe_config["auth"] = safe_auth
return safe_config
class ConfigUpdate(BaseModel):
data: dict
@app.put("/api/config")
async def update_config(payload: ConfigUpdate):
from config import _deep_merge
_deep_merge(config.data, payload.data)
config.save()
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)
+78
View File
@@ -0,0 +1,78 @@
import os
import yaml
DEFAULT_CONFIG = {
"recording": {
"download_path": "/app/data/recordings",
"max_retries": 5,
"retry_delay": 5,
},
"server": {
"host": "0.0.0.0",
"port": 8484,
},
"auth": {
"enabled": False,
"username": "admin",
"password": "stream-recorder",
},
"ntfy": {
"url": "",
"token": "",
"events": "error",
},
"plik": {
"url": "",
"api_key": "",
"ttl": "30d",
},
"logging": {
"level": "INFO",
},
}
class Config:
def __init__(self, config_path: str = "/app/data/config.yml"):
self.config_path = config_path
self.data = {}
self.load()
def load(self):
self.data = _deep_copy(DEFAULT_CONFIG)
if os.path.exists(self.config_path):
with open(self.config_path, "r") as f:
user_config = yaml.safe_load(f) or {}
_deep_merge(self.data, user_config)
else:
self.save()
def save(self):
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
with open(self.config_path, "w") as f:
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
def get(self, *keys, default=None):
val = self.data
for key in keys:
if isinstance(val, dict) and key in val:
val = val[key]
else:
return default
return val
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
+86
View File
@@ -0,0 +1,86 @@
import datetime
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Text
from sqlalchemy.orm import declarative_base, sessionmaker
Base = declarative_base()
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="")
description = Column(Text, default="")
plik_enabled = Column(Boolean, default=False)
delete_after_upload = Column(Boolean, default=False)
ntfy_enabled = Column(Boolean, default=False)
metadata_monitor_enabled = Column(Boolean, default=False)
metadata_pattern = Column(String(200), default="")
metadata_grace_period = Column(Integer, default=300)
metadata_poll_interval = Column(Integer, default=30)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.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=datetime.datetime.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)
class LogEntry(Base):
__tablename__ = "logs"
id = Column(Integer, primary_key=True, autoincrement=True)
timestamp = Column(DateTime, default=datetime.datetime.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})
Base.metadata.create_all(_engine)
_SessionLocal = sessionmaker(bind=_engine)
def get_session():
return _SessionLocal()
View File
+138
View File
@@ -0,0 +1,138 @@
"""
Euer-Radio Metadata-Monitor Plugin.
Polls RTMP stream metadata via ffprobe to detect whether a specific show
is currently live. When the show ends (pattern disappears from metadata),
the recording is stopped after a configurable grace period.
"""
import json
import subprocess
import threading
import time
from typing import Optional, Callable
def probe_stream_title(stream_url: str, timeout: int = 10) -> Optional[str]:
"""Query the current title tag from a live stream via ffprobe."""
cmd = [
"ffprobe", "-v", "quiet",
"-print_format", "json",
"-show_entries", "format_tags",
stream_url,
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
)
if result.returncode != 0:
return None
data = json.loads(result.stdout)
return data.get("format", {}).get("tags", {}).get("title")
except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception):
return None
def title_matches_pattern(title: str, pattern: str) -> bool:
"""
Check whether a stream title matches the configured show pattern.
TODO: Finalize once we've seen real live-show metadata from euer.tv.
Current assumption: the title contains the pattern string as a prefix
or substring, e.g. "Donnerwolke live - Artist - Song".
"""
if not title or not pattern:
return False
return pattern.lower() in title.lower()
class MetadataMonitor:
"""Monitors a stream's metadata and stops the recording when a show ends."""
def __init__(self, log_fn: Callable, stop_fn: Callable):
self._log = log_fn
self._stop_recording = stop_fn
self._monitors: dict[int, threading.Thread] = {}
self._cancel_events: dict[int, threading.Event] = {}
def start_monitoring(
self,
rec_id: int,
stream_url: str,
pattern: str,
grace_period: int = 300,
poll_interval: int = 30,
job_name: str = "",
):
if rec_id in self._monitors:
return
cancel = threading.Event()
self._cancel_events[rec_id] = cancel
thread = threading.Thread(
target=self._monitor_loop,
args=(rec_id, stream_url, pattern, grace_period, poll_interval, cancel, job_name),
daemon=True,
)
self._monitors[rec_id] = thread
thread.start()
self._log("INFO", f"Metadata-Monitor gestartet für '{job_name}' (Pattern: '{pattern}')", job_name)
def stop_monitoring(self, rec_id: int):
cancel = self._cancel_events.pop(rec_id, None)
if cancel:
cancel.set()
self._monitors.pop(rec_id, None)
def is_monitoring(self, rec_id: int) -> bool:
return rec_id in self._monitors
def _monitor_loop(
self,
rec_id: int,
stream_url: str,
pattern: str,
grace_period: int,
poll_interval: int,
cancel: threading.Event,
job_name: str,
):
grace_start = None
while not cancel.is_set():
title = probe_stream_title(stream_url)
if title is None:
if cancel.wait(timeout=poll_interval):
break
continue
if title_matches_pattern(title, pattern):
if grace_start is not None:
self._log("INFO",
f"Show '{pattern}' wieder erkannt, Karenz abgebrochen [{job_name}]",
job_name)
grace_start = None
else:
if grace_start is None:
grace_start = time.time()
self._log("INFO",
f"Show '{pattern}' nicht mehr erkannt, Karenzzeit läuft ({grace_period}s) [{job_name}]",
job_name)
elif time.time() - grace_start >= grace_period:
self._log("INFO",
f"Karenzzeit abgelaufen, Aufnahme wird gestoppt [{job_name}]",
job_name)
self._stop_recording(rec_id)
break
if cancel.wait(timeout=poll_interval):
break
self._monitors.pop(rec_id, None)
self._cancel_events.pop(rec_id, None)
+65
View File
@@ -0,0 +1,65 @@
"""NTFY push notification plugin."""
import subprocess
import threading
EVENT_TAGS = {
"start": "red_circle",
"stop": "white_check_mark",
"error": "rotating_light",
"upload": "outbox_tray",
}
def send_notification(
url: str,
title: str,
message: str,
token: str = "",
priority: str = "default",
tags: str = "",
):
if not url:
return
cmd = [
"curl", "-s", "-o", "/dev/null", "--max-time", "10",
"-H", f"Title: {title}",
"-H", f"Priority: {priority}",
]
if tags:
cmd += ["-H", f"Tags: {tags}"]
cmd += ["-d", message]
if token:
cmd += ["-H", f"Authorization: Bearer {token}"]
cmd.append(url)
def _send():
try:
subprocess.run(cmd, capture_output=True, timeout=15, stdin=subprocess.DEVNULL)
except Exception:
pass
threading.Thread(target=_send, daemon=True).start()
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")
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)
+168
View File
@@ -0,0 +1,168 @@
"""Plik file upload plugin."""
import json
import os
import re
import subprocess
import threading
from typing import Optional, Callable
def _parse_ttl_seconds(ttl: str) -> Optional[int]:
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 upload_file(
file_path: str,
plik_url: str,
api_key: str = "",
ttl: str = "30d",
) -> Optional[dict]:
"""Upload a file to Plik. Returns dict with 'browser_url' and 'download_url', or None on failure."""
if not plik_url or not os.path.isfile(file_path):
return None
if os.path.getsize(file_path) == 0:
return None
plik_base = plik_url.rstrip("/")
filename = os.path.basename(file_path)
cmd = ["curl", "-sS", "--max-time", "7200"]
if api_key:
cmd += ["-H", f"X-PlikToken: {api_key}"]
cmd += ["-F", f"file=@{file_path};filename={filename}"]
ttl_secs = _parse_ttl_seconds(ttl)
if ttl_secs is not None:
cmd += ["-F", f"ttl={ttl_secs}"]
cmd.append(plik_base)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=7200, stdin=subprocess.DEVNULL)
if result.returncode != 0:
return None
try:
data = json.loads(result.stdout)
upload_id = data.get("id", "")
if not upload_id:
return None
browser_url = f"{plik_base}/#/?id={upload_id}"
download_url = None
files = data.get("files", [])
if files:
file_id = files[0].get("id", "")
file_name = files[0].get("fileName", filename)
if file_id:
download_url = f"{plik_base}/file/{upload_id}/{file_id}/{file_name}"
return {"browser_url": browser_url, "download_url": download_url}
except (json.JSONDecodeError, KeyError):
urls = re.findall(r'https?://\S+', result.stdout)
if urls:
download_url = urls[-1]
prefix = f"{plik_base}/file/"
if download_url.startswith(prefix):
rest = download_url[len(prefix):]
uid = rest.split("/")[0]
if uid:
return {"browser_url": f"{plik_base}/#/?id={uid}", "download_url": download_url}
return {"browser_url": download_url, "download_url": download_url}
return None
except Exception:
return None
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, output_dir: str, output_file: str, job_name: str,
segment_mode: bool, delete_after: bool, ntfy_enabled: bool):
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")
def _do_upload():
try:
if segment_mode:
self._upload_segments(output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled)
else:
self._upload_single(output_file, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled)
except Exception as e:
self._log("ERROR", f"Plik-Upload Fehler: {e} [{job_name}]", job_name)
threading.Thread(target=_do_upload, daemon=True).start()
def _upload_single(self, file_path, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled):
if not os.path.isfile(file_path) or os.path.getsize(file_path) == 0:
self._log("WARN", f"Plik-Upload übersprungen: Datei fehlt oder leer [{job_name}]", job_name)
return
self._log("INFO", f"Plik-Upload: {os.path.basename(file_path)} [{job_name}]", job_name)
result = upload_file(file_path, plik_url, api_key, ttl)
if result:
self._log("INFO", f"Plik-Upload abgeschlossen: {result['browser_url']} [{job_name}]", job_name)
if result.get("download_url"):
self._log("INFO", f"Plik-Download: {result['download_url']} [{job_name}]", job_name)
if self._notify:
self._notify("upload", "Plik-Upload", f"{job_name}: {result['browser_url']}", ntfy_enabled)
if delete_after:
os.remove(file_path)
self._log("INFO", f"Lokale Datei gelöscht: {file_path} [{job_name}]", job_name)
else:
self._log("ERROR", f"Plik-Upload fehlgeschlagen [{job_name}]", job_name)
def _upload_segments(self, output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled):
if not os.path.isdir(output_dir):
return
files = sorted(f for f in os.listdir(output_dir) if not f.startswith(".") and os.path.isfile(os.path.join(output_dir, f)))
if not files:
return
uploaded = 0
last_browser_url = None
for fname in files:
fpath = os.path.join(output_dir, fname)
if os.path.getsize(fpath) == 0:
continue
self._log("INFO", f"Plik Segment-Upload: {fname} [{job_name}]", job_name)
result = upload_file(fpath, plik_url, api_key, ttl)
if result:
last_browser_url = result["browser_url"]
self._log("INFO", f"Plik Segment hochgeladen: {last_browser_url} [{job_name}]", job_name)
uploaded += 1
if delete_after:
os.remove(fpath)
self._log("INFO", f"Segment gelöscht: {fname} [{job_name}]", job_name)
else:
self._log("ERROR", f"Plik Segment-Upload fehlgeschlagen: {fname} [{job_name}]", job_name)
if uploaded > 0 and self._notify:
msg = f"{job_name}: {uploaded} Segment(e) hochgeladen"
if last_browser_url:
msg += f"\n{last_browser_url}"
self._notify("upload", "Plik-Upload", msg, ntfy_enabled)
+507
View File
@@ -0,0 +1,507 @@
import asyncio
import datetime
import os
import re
import signal
import subprocess
import threading
import time
from pathlib import Path
from typing import Optional
from models import Recording, get_session, LogEntry
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._metadata_monitor = None
self._ntfy = None
self._plik = None
self._rec_options: dict[int, dict] = {}
def _get_metadata_monitor(self):
if self._metadata_monitor is None:
from plugins.euer_radio import MetadataMonitor
self._metadata_monitor = MetadataMonitor(
log_fn=self._log,
stop_fn=self.stop_recording,
)
return self._metadata_monitor
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):
session = get_session()
try:
entry = LogEntry(level=level, message=message, job_name=job_name)
session.add(entry)
session.commit()
finally:
session.close()
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 = "",
metadata_monitor_enabled: bool = False,
metadata_pattern: str = "",
metadata_grace_period: int = 300,
metadata_poll_interval: int = 30,
ntfy_enabled: bool = False,
plik_enabled: bool = False,
delete_after_upload: bool = False,
) -> dict:
if stream_type == "auto":
stream_type = detect_stream_type(stream_url)
ext = output_format if output_format and output_format.lower() != "auto" else default_extension(stream_type)
sname = safe_name(name)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
download_path = self.config.get("recording", "download_path", default="/app/data/recordings")
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 = datetime.datetime.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()
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._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._log("INFO", f"Aufnahme gestartet: {name}", name)
self._notify("start", "Aufnahme gestartet", name, rec_id)
if metadata_monitor_enabled and metadata_pattern:
self._get_metadata_monitor().start_monitoring(
rec_id=rec_id,
stream_url=stream_url,
pattern=metadata_pattern,
grace_period=metadata_grace_period,
poll_interval=metadata_poll_interval,
job_name=name,
)
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
while not stop_event.is_set():
cmd = self._build_ffmpeg_cmd(
stream_url, stream_type, output_file,
extra_ffmpeg_args, segment_mode, segment_duration,
)
try:
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:
self._update_recording(rec_id, status="error", error_message=str(e))
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)
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)
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")
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
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}")
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")
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._update_recording(rec_id, status="stopped")
def _build_ffmpeg_cmd(self, url, stream_type, output_file, extra_args, segment_mode, segment_duration):
cmd = ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "warning"]
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"]
if extra_args:
cmd += extra_args.split()
cmd.append(output_file)
return cmd
def stop_recording(self, rec_id: int) -> bool:
if self._metadata_monitor:
self._metadata_monitor.stop_monitoring(rec_id)
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
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) -> bool:
session = get_session()
try:
rec = session.query(Recording).filter_by(id=rec_id).first()
if not rec or rec.status != "running":
return False
if rec.scheduled_stop:
rec.scheduled_stop = rec.scheduled_stop + datetime.timedelta(seconds=extra_seconds)
else:
rec.scheduled_stop = datetime.datetime.utcnow() + datetime.timedelta(seconds=extra_seconds)
session.commit()
self._log("INFO", f"Aufnahme verlängert um {format_elapsed(extra_seconds)}: {rec.job_name}", rec.job_name)
return True
finally:
session.close()
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 = datetime.datetime.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):
session = get_session()
try:
recs = session.query(Recording).filter(Recording.status.in_(["running", "starting"])).all()
for rec in recs:
if rec.pid:
try:
os.kill(rec.pid, 0)
except (ProcessLookupError, OSError):
rec.status = "error"
rec.error_message = "Process vanished"
rec.stopped_at = datetime.datetime.utcnow()
session.commit()
finally:
session.close()
def _recording_to_dict(self, rec: Recording) -> dict:
elapsed = 0
if rec.started_at:
end = rec.stopped_at or datetime.datetime.utcnow()
elapsed = int((end - rec.started_at).total_seconds())
total_size = 0
segment_count = 0
if rec.output_dir and os.path.isdir(rec.output_dir):
for f in os.listdir(rec.output_dir):
if f.startswith("."):
continue
fp = os.path.join(rec.output_dir, f)
if os.path.isfile(fp):
total_size += os.path.getsize(fp)
segment_count += 1
elif rec.output_file and os.path.isfile(rec.output_file):
total_size = os.path.getsize(rec.output_file)
remaining = None
if rec.scheduled_stop and rec.status == "running":
diff = (rec.scheduled_stop - datetime.datetime.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": rec.started_at.isoformat() if rec.started_at else None,
"stopped_at": rec.stopped_at.isoformat() if rec.stopped_at else None,
"scheduled_stop": rec.scheduled_stop.isoformat() if rec.scheduled_stop else None,
"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,
}
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 = datetime.datetime.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"):
self._get_plik().upload_recording(
output_dir=output_dir,
output_file=output_file,
job_name=job_name,
segment_mode=segment_mode,
delete_after=opts.get("delete_after_upload", False),
ntfy_enabled=opts.get("ntfy_enabled", False),
)
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)
+8
View File
@@ -0,0 +1,8 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
sqlalchemy==2.0.40
pydantic==2.11.3
apscheduler==3.11.0
pyyaml==6.0.2
python-multipart==0.0.20
httpx==0.28.1
+211
View File
@@ -0,0 +1,211 @@
import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from models import Job, Recording, get_session
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,
}
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 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 _time_to_minutes(t: str) -> int:
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
class StreamScheduler:
def __init__(self, recorder):
self.recorder = recorder
self._scheduler = BackgroundScheduler()
self._started_keys: set[str] = set()
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.start()
def stop(self):
self._scheduler.shutdown(wait=False)
def _occurrence_key(self, job: Job) -> str:
today = datetime.date.today().isoformat()
if job.schedule_once:
today = job.schedule_date or today
return f"{job.id}|{job.schedule_once}|{today}|{job.schedule_start}"
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):
session = get_session()
try:
rec = session.query(Recording).filter(
Recording.job_id == job_id,
Recording.status.in_(["running", "starting"]),
Recording.is_scheduled == True,
).first()
return rec.id if rec else None
finally:
session.close()
def _tick(self):
now = datetime.datetime.now()
today_num = now.isoweekday()
today_date = now.strftime("%Y-%m-%d")
now_time = now.strftime("%H:%M")
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,
"description": j.description,
"metadata_monitor_enabled": j.metadata_monitor_enabled,
"metadata_pattern": j.metadata_pattern,
"metadata_grace_period": j.metadata_grace_period,
"metadata_poll_interval": j.metadata_poll_interval,
"ntfy_enabled": j.ntfy_enabled,
"plik_enabled": j.plik_enabled,
"delete_after_upload": j.delete_after_upload,
})
finally:
session.close()
for job in job_list:
if not job["schedule_start"]:
continue
is_today = False
multi_day = bool(job.get("schedule_end_date") and job["schedule_once"])
if job["schedule_once"]:
if job["schedule_date"] == today_date:
is_today = True
else:
if _matches_day(today_num, job["schedule_days"] or "*"):
is_today = True
in_window = False
is_cross_midnight = False
if is_today:
if multi_day:
now_min = _time_to_minutes(now_time)
start_min = _time_to_minutes(job["schedule_start"])
in_window = now_min >= start_min
elif job["schedule_stop"]:
start_min = _time_to_minutes(job["schedule_start"])
stop_min = _time_to_minutes(job["schedule_stop"])
if stop_min <= start_min:
is_cross_midnight = True
now_min = _time_to_minutes(now_time)
in_window = now_min >= start_min
else:
in_window = _in_time_window(now_time, job["schedule_start"], job["schedule_stop"])
else:
now_min = _time_to_minutes(now_time)
start_min = _time_to_minutes(job["schedule_start"])
in_window = now_min >= start_min
occ_key = f"{job['id']}|{job['schedule_once']}|{today_date if not job['schedule_once'] else job['schedule_date']}|{job['schedule_start']}"
if in_window:
if occ_key not in self._started_keys and not self._is_job_recording(job["id"]):
duration = job["max_duration"]
if not duration and job["schedule_stop"]:
if multi_day:
start_dt = datetime.datetime.strptime(
f"{job['schedule_date']} {job['schedule_start']}", "%Y-%m-%d %H:%M")
end_dt = datetime.datetime.strptime(
f"{job['schedule_end_date']} {job['schedule_stop']}", "%Y-%m-%d %H:%M")
duration = max(0, int((end_dt - start_dt).total_seconds()))
else:
start_min = _time_to_minutes(job["schedule_start"])
stop_min = _time_to_minutes(job["schedule_stop"])
if stop_min > start_min:
duration = (stop_min - start_min) * 60
else:
duration = (1440 - start_min + stop_min) * 60
self.recorder.start_recording(
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", ""),
metadata_monitor_enabled=job.get("metadata_monitor_enabled", False),
metadata_pattern=job.get("metadata_pattern", ""),
metadata_grace_period=job.get("metadata_grace_period") or 300,
metadata_poll_interval=job.get("metadata_poll_interval") or 30,
ntfy_enabled=job.get("ntfy_enabled", False),
plik_enabled=job.get("plik_enabled", False),
delete_after_upload=job.get("delete_after_upload", False),
)
self._started_keys.add(occ_key)
else:
if not multi_day and not is_cross_midnight and job["schedule_stop"] and self._is_job_recording(job["id"]):
rec_id = self._get_active_scheduled_recording_for_job(job["id"])
if rec_id:
self.recorder.stop_recording(rec_id)
self._started_keys.discard(occ_key)
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
###############################################
# 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
download_path: /app/data/recordings
# Wiederholungsversuche bei Verbindungsabbruch (0 = unbegrenzt)
max_retries: 5
# Wartezeit zwischen Wiederholungsversuchen (Sekunden)
retry_delay: 5
server:
host: "0.0.0.0"
port: 8484
# Optionale Basic-Auth
auth:
enabled: false
username: admin
password: stream-recorder
# NTFY Push-Benachrichtigungen (optional)
ntfy:
url: ""
token: ""
events: "error"
# Plik-Upload (optional)
plik:
url: ""
api_key: ""
ttl: "30d"
logging:
level: INFO
-62
View File
@@ -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"
+32
View File
@@ -0,0 +1,32 @@
services:
stream-recorder:
image: git.techniverse.net/scriptos/stream-recorder:latest
container_name: stream-recorder
restart: unless-stopped
ports:
- "${APP_PORT_BIND:-8484}:8484"
volumes:
- ./data:/app/data
# Optional: Recordings auf separatem Pfad (dann download_path in Einstellungen anpassen)
# - /mnt/storage/recordings:/recordings
networks:
stream-recorder_net:
ipv4_address: ${APP_IPV4_ADDRESS:-172.16.84.10}
environment:
TZ: ${TZ:-Europe/Berlin}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8484/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
stream-recorder_net:
name: ${DOCKER_NETWORK_NAME:-stream-recorder.dockernetwork.local}
driver: bridge
ipam:
config:
- subnet: ${DOCKER_NETWORK_SUBNET:-172.16.84.0/24}
gateway: ${DOCKER_NETWORK_GATEWAY:-172.16.84.1}
ip_range: ${DOCKER_NETWORK_IP_RANGE:-172.16.84.128/25}
+65
View File
@@ -0,0 +1,65 @@
# REST-API
Alle Funktionen des Web-UI sind auch per REST-API verfügbar. Basis-URL: `http://<host>:8484`
## Endpunkte
### System
| Methode | Pfad | Beschreibung |
|---------|------|-------------|
| `GET` | `/api/health` | Health Check (kein Auth nötig) |
| `GET` | `/api/config` | Konfiguration lesen |
| `PUT` | `/api/config` | Konfiguration speichern |
### 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 |
| `POST` | `/api/jobs/:id/record` | Job-Aufnahme starten |
### 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
}
```
### 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 |
| `POST` | `/api/recordings/:id/unlimited` | Zeitlimit entfernen |
### Logs
| Methode | Pfad | Beschreibung |
|---------|------|-------------|
| `GET` | `/api/logs` | Protokoll (Parameter: `limit`, `level`) |
## Authentifizierung
Bei aktiviertem Auth wird Basic Authentication verwendet:
```bash
curl -u admin:passwort http://localhost:8484/api/jobs
```
Der Health-Endpunkt (`/api/health`) ist immer ohne Auth erreichbar.
+57
View File
@@ -0,0 +1,57 @@
# Aufnahmen
## Aktive Aufnahmen
Das Dashboard zeigt alle laufenden Aufnahmen mit:
- Laufzeit
- Dateigröße
- Verbleibende Zeit (bei Zeitlimit)
- Segment-Anzahl (bei Segment-Splitting)
## Aufnahme verlängern
Laufende Aufnahmen können verlängert werden:
- **+15 / +30 / +60 / +120 Minuten** — Schnellauswahl
- **Eigene Dauer** — Beliebige Minutenzahl eingeben
- **Unbegrenzt** — Zeitlimit komplett entfernen
## 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.
## 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.
-105
View File
@@ -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 |
-157
View File
@@ -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
```
-41
View File
@@ -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
-65
View File
@@ -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
```
+39 -61
View File
@@ -1,80 +1,58 @@
# 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
### Interaktiv (empfohlen)
Im Web-UI unter **Jobs → + Neuer Job**.
```bash
./stream-recorder.sh create
```
### Pflichtfelder
Das Script fragt Schritt für Schritt nach URL, Name, Typ, Segment-Splitting, Zeitplan und Plik-Upload und erstellt die Job-Datei automatisch.
| Feld | Beschreibung |
|------|-------------|
| **Name** | Eindeutiger Name (wird als Ordnername verwendet) |
| **Stream-URL** | URL des Streams (RTMP, HLS, HTTP, etc.) |
### Aus Vorlage
### Optionale Felder
```bash
# Vorlage kopieren
cp jobs/templates/example-rtmp.job.dist jobs/mein-stream.job
| Feld | Beschreibung |
|------|-------------|
| **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`) |
# Anpassen
nano jobs/mein-stream.job
```
## Zeitplan
Verfügbare Vorlagen:
Aktiviert automatische Aufnahmen zu festgelegten Zeiten.
| Vorlage | Beschreibung |
|----------------------------|-------------------------|
| `example-rtmp.job.dist` | RTMP/RTMPS-Stream |
| `example-hls.job.dist` | HLS/m3u8-Stream |
| `example-mp3.job.dist` | HTTP-Audio (MP3/Radio) |
| Feld | Beschreibung |
|------|-------------|
| **Einmalig** | Nur einmal am angegebenen Datum ausführen |
| **Datum** | Datum für einmalige Aufnahme |
| **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 |
|----------------------|---------|-----------------------------------------------------|
| `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`) |
### Tage-Syntax
### 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 |
|-----------|------------------|
| `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 |
## Manuelle Aufnahme
Formate lassen sich frei kombinieren: `d` (Tage), `h` (Stunden), `m` (Minuten), `s` (Sekunden).
Jobs können auch manuell über den **Aufnehmen**-Button gestartet werden, unabhängig vom Zeitplan.
## Job-Angabe beim Starten
## Schnellaufnahme
Der `record`-Befehl akzeptiert verschiedene Formate:
```bash
./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
```bash
./stream-recorder.sh list
```
Zeigt alle Jobs mit Stream-Typ, URL, Status, Zeitplan, Segment-Einstellungen und Plik-Konfiguration.
Für einmalige Aufnahmen ohne Job: **Dashboard → + Schnellaufnahme**. URL eingeben, optional Name und Dauer angeben, sofort starten.
+47 -48
View File
@@ -1,66 +1,65 @@
# Konfiguration
Die Konfigurationsdatei liegt unter `config/stream-recorder.conf`. Sie wird aus der Vorlage `config/stream-recorder.conf.dist` erstellt:
## config.yml
```bash
cp config/stream-recorder.conf.dist config/stream-recorder.conf
nano config/stream-recorder.conf
```
Die Konfiguration liegt in `data/config.yml` und kann über das Web-UI unter **Einstellungen** bearbeitet werden.
Die `.conf`-Datei wird von Git ignoriert und bleibt bei Updates erhalten.
Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine Vorlage mit Kommentaren findet sich in `config/config.yml.dist`.
## Parameter
## Abschnitte
### Basis-Einstellungen
### Aufnahme
| Parameter | Standard | Beschreibung |
|-----------------|------------------------------|-------------------------------------------|
| `DOWNLOAD_PATH` | `/home/downloads/recordings` | Basis-Verzeichnis für Aufnahmen |
| `LOG_DIR` | `/var/log/stream-recorder` | Log-Verzeichnis |
| `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 |
|-----------|----------|-------------|
| `recording.download_path` | `/app/data/recordings` | Speicherort für Aufnahmen |
| `recording.max_retries` | `5` | Max. Reconnect-Versuche bei Verbindungsabbruch |
| `recording.retry_delay` | `5` | Wartezeit (Sekunden) zwischen Reconnect-Versuchen |
### Reconnect
### Authentifizierung
| Parameter | Standard | Beschreibung |
|---------------|----------|-------------------------------------------|
| `MAX_RETRIES` | `0` | Wiederholungsversuche (0 = unbegrenzt) |
| `RETRY_DELAY` | `5` | Wartezeit zwischen Versuchen (Sekunden) |
|-----------|----------|-------------|
| `auth.enabled` | `false` | Basic Auth aktivieren |
| `auth.username` | `admin` | Benutzername |
| `auth.password` | `stream-recorder` | Passwort |
Bei Verbindungsabbrüchen versucht das Script automatisch, die Verbindung wiederherzustellen. Bei jedem Reconnect wird eine neue Datei mit aktuellem Zeitstempel erzeugt.
### NTFY
- `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 |
|---------------|----------|-------------------------------------------|
| `NTFY_URL` | (leer) | NTFY Server-URL inkl. Topic |
| `NTFY_TOKEN` | (leer) | NTFY Access-Token (optional) |
| `NTFY_EVENTS` | `error` | Benachrichtigungs-Events (kommasepariert) |
Siehe [Benachrichtigungen](benachrichtigungen.md) für Details zu den Events.
### Plik-Upload
|-----------|----------|-------------|
| `ntfy.url` | _(leer)_ | NTFY Topic-URL (z.B. `https://ntfy.sh/mein-topic`) |
| `ntfy.token` | _(leer)_ | Bearer-Token für authentifizierte Topics |
| `ntfy.events` | `error` | Kommaseparierte Events: `start`, `stop`, `error`, `upload` |
Fertige Aufnahmen auf einen [Plik](https://github.com/root-gg/plik)-Server hochladen.
### Plik
| Parameter | Standard | Beschreibung |
|----------------|----------|-------------------------------------------|
| `PLIK_URL` | (leer) | Plik Server-URL |
| `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.
## Alternative Konfigurationsdatei
Eine andere Konfigurationsdatei kann per `-c` übergeben werden:
```bash
./stream-recorder.sh -c /pfad/zur/config.conf record mein-stream
|-----------|----------|-------------|
| `plik.url` | _(leer)_ | Plik-Server URL (z.B. `https://plik.example.com`) |
| `plik.api_key` | _(leer)_ | API Key für authentifizierte Uploads |
| `plik.ttl` | `30d` | Aufbewahrungsdauer der Uploads (`30d`, `12h`, etc.) |
## Beispiel
```yaml
recording:
download_path: /app/data/recordings
max_retries: 5
retry_delay: 5
auth:
enabled: true
username: admin
password: mein-passwort
ntfy:
url: https://ntfy.sh/stream-recorder
token: ""
events: start,stop,error,upload
plik:
url: https://plik.example.com
api_key: ""
ttl: 30d
```
-62
View File
@@ -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"
```
+85
View File
@@ -0,0 +1,85 @@
# 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.
---
## 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 |
| **Nach Upload 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
Bei aktiviertem NTFY wird die Browser-URL in der Push-Nachricht mitgeschickt.
### Segment-Upload
Bei Segment-Splitting werden alle Segmente einzeln hochgeladen. Die NTFY-Nachricht enthält die Anzahl der hochgeladenen Segmente.
---
## Euer-Radio Metadata-Monitor
Erkennt automatisch, ob eine bestimmte Show live sendet, indem der Stream-Titel via `ffprobe` abgefragt wird. Wenn das konfigurierte Pattern nicht mehr im Titel erscheint, wird die Aufnahme nach einer Karenzzeit gestoppt.
### Einrichtung
Pro Job im Web-UI:
| Option | Standard | Beschreibung |
|--------|----------|-------------|
| **Show-Pattern** | _(leer)_ | Text, der im Stream-Titel vorkommen muss (z.B. Sendungsname) |
| **Karenzzeit** | 5 Min. | Wie lange gewartet wird, nachdem das Pattern verschwunden ist |
| **Poll-Intervall** | 30 Sek. | Wie oft der Stream-Titel abgefragt wird |
### Funktionsweise
1. Der Monitor fragt regelmäßig den Stream-Titel per `ffprobe` ab
2. Solange das Pattern im Titel vorkommt, läuft die Aufnahme weiter
3. Wenn das Pattern verschwindet, startet die Karenzzeit
4. Taucht das Pattern innerhalb der Karenzzeit wieder auf, wird der Timer zurückgesetzt
5. Läuft die Karenzzeit ab, wird die Aufnahme gestoppt
### Anwendungsfall
Ideal für Streams mit wechselnden Shows (z.B. Internet-Radio), bei denen die Aufnahmedauer nicht vorher feststeht. Der Stream-Titel zeigt an, welche Show gerade läuft, und der Monitor erkennt automatisch das Ende.
-76
View File
@@ -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.
-80
View File
@@ -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"
```
-104
View File
@@ -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"
-61
View File
@@ -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."
-49
View File
@@ -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
-50
View File
@@ -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
-74
View File
@@ -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
-1621
View File
File diff suppressed because it is too large Load Diff