6 Commits
Author SHA1 Message Date
Patrick Asmus c3f68ace96 Logging erweitert. 2026-09-20 22:31:59 +02:00
Patrick AsmusandClaude Opus 4.6 6d07b12015 APP_VERSION aus Build-Arg statt Hardcoding im Code
Build Release Docker Image / build-amd64 (release) Successful in 8s
Build Release Docker Image / build-arm64 (release) Successful in 14s
Build Release Docker Image / publish-release-manifest (release) Successful in 9s
Version wird jetzt beim Docker-Build per --build-arg injiziert.
Release-Workflow leitet sie aus dem Tag-Namen ab, Dev-Workflow
nutzt den Short-SHA. Verhindert Version-Mismatch bei Releases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-16 20:32:47 +02:00
Patrick Asmus 50bb72803f Release: v3.1.0 2026-09-16 20:25:41 +02:00
Patrick Asmus a9a9a89cba Release: v3.1.0
Build Release Docker Image / build-amd64 (release) Successful in 6s
Build Release Docker Image / build-arm64 (release) Successful in 10s
Build Release Docker Image / publish-release-manifest (release) Successful in 6s
2026-09-16 20:22:34 +02:00
Patrick Asmus ce1ccb91bb Release: v3.1.0
Build Dev Docker Image / build-amd64 (push) Failing after 7s
Build Dev Docker Image / build-arm64 (push) Failing after 9s
Build Dev Docker Image / publish-dev-manifest (push) Skipped
2026-09-16 20:22:25 +02:00
Patrick AsmusandClaude Opus 4.6 851ff086ac Dateigröße pro Aufnahme entkoppelt - feste Größe nach Abschluss
Build Dev Docker Image / build-amd64 (push) Successful in 10s
Build Dev Docker Image / build-arm64 (push) Successful in 16s
Build Dev Docker Image / publish-dev-manifest (push) Successful in 9s
Die Dateigröße wird jetzt pro Aufnahme korrekt berechnet statt das
gesamte Verzeichnis zu summieren. Bei abgeschlossenen Aufnahmen wird
die Größe einmalig gespeichert und nicht mehr durch laufende Aufnahmen
verändert.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-16 11:46:18 +02:00
28 changed files with 1205 additions and 463 deletions
+7 -1
View File
@@ -3,7 +3,7 @@ name: Build Dev Docker Image
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: [main, dev] branches: [dev]
paths-ignore: paths-ignore:
- 'docs/**' - 'docs/**'
- 'README.md' - 'README.md'
@@ -27,7 +27,10 @@ jobs:
- name: Build and push amd64 image - name: Build and push amd64 image
run: | run: |
SHORT_SHA="${GITHUB_SHA:0:7}"
docker build --pull --platform linux/amd64 \ docker build --pull --platform linux/amd64 \
--build-arg APP_VERSION="dev-${SHORT_SHA}" \
-t "${IMAGE}:dev-amd64" \ -t "${IMAGE}:dev-amd64" \
-t "${IMAGE}:dev-${GITHUB_SHA}-amd64" \ -t "${IMAGE}:dev-${GITHUB_SHA}-amd64" \
. .
@@ -47,7 +50,10 @@ jobs:
- name: Build and push arm64 image - name: Build and push arm64 image
run: | run: |
SHORT_SHA="${GITHUB_SHA:0:7}"
docker build --pull --platform linux/arm64 \ docker build --pull --platform linux/arm64 \
--build-arg APP_VERSION="dev-${SHORT_SHA}" \
-t "${IMAGE}:dev-arm64" \ -t "${IMAGE}:dev-arm64" \
-t "${IMAGE}:dev-${GITHUB_SHA}-arm64" \ -t "${IMAGE}:dev-${GITHUB_SHA}-arm64" \
. .
+4
View File
@@ -23,8 +23,10 @@ jobs:
- name: Build and push amd64 release image - name: Build and push amd64 release image
run: | run: |
RELEASE_TAG="${GITHUB_REF_NAME}" RELEASE_TAG="${GITHUB_REF_NAME}"
VERSION="${RELEASE_TAG#v}"
docker build --pull --platform linux/amd64 \ docker build --pull --platform linux/amd64 \
--build-arg APP_VERSION="${VERSION}" \
-t "${IMAGE}:${RELEASE_TAG}-amd64" \ -t "${IMAGE}:${RELEASE_TAG}-amd64" \
. .
docker push "${IMAGE}:${RELEASE_TAG}-amd64" docker push "${IMAGE}:${RELEASE_TAG}-amd64"
@@ -43,8 +45,10 @@ jobs:
- name: Build and push arm64 release image - name: Build and push arm64 release image
run: | run: |
RELEASE_TAG="${GITHUB_REF_NAME}" RELEASE_TAG="${GITHUB_REF_NAME}"
VERSION="${RELEASE_TAG#v}"
docker build --pull --platform linux/arm64 \ docker build --pull --platform linux/arm64 \
--build-arg APP_VERSION="${VERSION}" \
-t "${IMAGE}:${RELEASE_TAG}-arm64" \ -t "${IMAGE}:${RELEASE_TAG}-arm64" \
. .
docker push "${IMAGE}:${RELEASE_TAG}-arm64" docker push "${IMAGE}:${RELEASE_TAG}-arm64"
+3 -1
View File
@@ -11,10 +11,12 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY bin/backend/ /app/ COPY bin/backend/ /app/
COPY bin/frontend/ /app/frontend/ COPY bin/frontend/ /app/frontend/
COPY config/config.yml.dist /app/config.yml.dist COPY bin/config.yml.dist /app/config.yml.dist
RUN mkdir -p /app/data/recordings RUN mkdir -p /app/data/recordings
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
ENV DATA_DIR=/app/data ENV DATA_DIR=/app/data
ENV STATIC_DIR=/app/frontend/static ENV STATIC_DIR=/app/frontend/static
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
+25 -4
View File
@@ -1,9 +1,30 @@
MIT License MIT License
Copyright (c) 2023 scriptos ---
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Copyright © Patrick Asmus (scriptos)
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. Website: https://www.patrick-asmus.de
Blog: https://www.cleveradmin.de
Email: support@techniverse.net
---
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+2
View File
@@ -21,6 +21,8 @@
</h6> </h6>
<br> <br>
![Dashboard Übersicht](docs/assets/img1_dashboard.png)
## Funktionen ## Funktionen
- **Web-Interface** — Jobs planen, Aufnahmen steuern und verlängern im Browser - **Web-Interface** — Jobs planen, Aufnahmen steuern und verlängern im Browser
+126 -20
View File
@@ -13,10 +13,12 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
from config import Config from config import Config
from models import init_db, get_session, Job, Recording as RecordingModel, LogEntry from models import init_db, get_session, Job, Station, Recording as RecordingModel, LogEntry
from recorder import RecordingManager, format_elapsed from recorder import RecordingManager, format_elapsed
from scheduler import StreamScheduler from scheduler import StreamScheduler
APP_VERSION = os.environ.get("APP_VERSION", "dev")
DATA_DIR = os.environ.get("DATA_DIR", "/app/data") DATA_DIR = os.environ.get("DATA_DIR", "/app/data")
STATIC_DIR = os.environ.get("STATIC_DIR", "/app/frontend/static") STATIC_DIR = os.environ.get("STATIC_DIR", "/app/frontend/static")
@@ -37,7 +39,7 @@ async def lifespan(app):
scheduler.stop() scheduler.stop()
app = FastAPI(title="stream-recorder", version="3.0.0", lifespan=lifespan) app = FastAPI(title="stream-recorder", version=APP_VERSION, lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -78,11 +80,111 @@ async def auth_middleware(request: Request, call_next):
@app.get("/api/health") @app.get("/api/health")
async def health(): async def health():
return {"status": "ok", "version": "3.0.0"} return {"status": "ok", "version": APP_VERSION}
# --- Jobs CRUD --- # --- Jobs CRUD ---
class StationCreate(BaseModel):
name: str
stream_url: str
stream_type: str = "auto"
description: str = ""
class StationUpdate(StationCreate):
pass
def _station_to_dict(station: Station) -> dict:
return {
"id": station.id,
"name": station.name,
"stream_url": station.stream_url,
"stream_type": station.stream_type,
"description": station.description,
"created_at": station.created_at.isoformat() if station.created_at else None,
"updated_at": station.updated_at.isoformat() if station.updated_at else None,
}
def _clean_station_data(data: StationCreate | StationUpdate) -> dict:
values = data.model_dump()
values["name"] = values["name"].strip()
values["stream_url"] = values["stream_url"].strip()
values["stream_type"] = (values.get("stream_type") or "auto").strip() or "auto"
values["description"] = (values.get("description") or "").strip()
if not values["name"] or not values["stream_url"]:
raise HTTPException(400, "Name und URL sind Pflichtfelder")
return values
@app.get("/api/stations")
async def list_stations():
session = get_session()
try:
stations = session.query(Station).order_by(Station.name.collate("NOCASE"), Station.id).all()
return [_station_to_dict(s) for s in stations]
finally:
session.close()
@app.post("/api/stations")
async def create_station(data: StationCreate):
values = _clean_station_data(data)
session = get_session()
try:
station = Station(**values)
session.add(station)
session.commit()
return {"id": station.id, "message": "Sender gespeichert"}
finally:
session.close()
@app.get("/api/stations/{station_id}")
async def get_station(station_id: int):
session = get_session()
try:
station = session.query(Station).filter_by(id=station_id).first()
if not station:
raise HTTPException(404, "Sender nicht gefunden")
return _station_to_dict(station)
finally:
session.close()
@app.put("/api/stations/{station_id}")
async def update_station(station_id: int, data: StationUpdate):
values = _clean_station_data(data)
session = get_session()
try:
station = session.query(Station).filter_by(id=station_id).first()
if not station:
raise HTTPException(404, "Sender nicht gefunden")
for k, v in values.items():
setattr(station, k, v)
station.updated_at = datetime.datetime.utcnow()
session.commit()
return {"message": "Sender aktualisiert"}
finally:
session.close()
@app.delete("/api/stations/{station_id}")
async def delete_station(station_id: int):
session = get_session()
try:
station = session.query(Station).filter_by(id=station_id).first()
if not station:
raise HTTPException(404, "Sender nicht gefunden")
session.delete(station)
session.commit()
return {"message": "Sender gelöscht"}
finally:
session.close()
class JobCreate(BaseModel): class JobCreate(BaseModel):
name: str name: str
stream_url: str stream_url: str
@@ -98,14 +200,13 @@ class JobCreate(BaseModel):
schedule_start: str = "" schedule_start: str = ""
schedule_stop: str = "" schedule_stop: str = ""
schedule_end_date: str = "" schedule_end_date: str = ""
pre_buffer: int = 0
post_buffer: int = 0
description: str = "" description: str = ""
ntfy_enabled: bool = False ntfy_enabled: bool = False
plik_enabled: bool = False plik_enabled: bool = False
delete_after_upload: bool = False delete_after_upload: bool = False
metadata_monitor_enabled: bool = False station_id: Optional[int] = None
metadata_pattern: str = ""
metadata_grace_period: int = 300
metadata_poll_interval: int = 30
class JobUpdate(JobCreate): class JobUpdate(JobCreate):
@@ -117,6 +218,13 @@ async def list_jobs():
session = get_session() session = get_session()
try: try:
jobs = session.query(Job).order_by(Job.id).all() jobs = session.query(Job).order_by(Job.id).all()
station_ids = {j.station_id for j in jobs if j.station_id}
station_names = {}
if station_ids:
for s in session.query(Station).filter(Station.id.in_(station_ids)).all():
station_names[s.id] = s.name
result = [] result = []
for j in jobs: for j in jobs:
is_recording = session.query( is_recording = session.query(
@@ -139,14 +247,14 @@ async def list_jobs():
"schedule_start": j.schedule_start, "schedule_start": j.schedule_start,
"schedule_stop": j.schedule_stop, "schedule_stop": j.schedule_stop,
"schedule_end_date": j.schedule_end_date, "schedule_end_date": j.schedule_end_date,
"pre_buffer": j.pre_buffer or 0,
"post_buffer": j.post_buffer or 0,
"description": j.description, "description": j.description,
"ntfy_enabled": j.ntfy_enabled, "ntfy_enabled": j.ntfy_enabled,
"plik_enabled": j.plik_enabled, "plik_enabled": j.plik_enabled,
"delete_after_upload": j.delete_after_upload, "delete_after_upload": j.delete_after_upload,
"metadata_monitor_enabled": j.metadata_monitor_enabled, "station_id": j.station_id,
"metadata_pattern": j.metadata_pattern, "station_name": station_names.get(j.station_id) if j.station_id else None,
"metadata_grace_period": j.metadata_grace_period,
"metadata_poll_interval": j.metadata_poll_interval,
"is_recording": is_recording, "is_recording": is_recording,
"created_at": j.created_at.isoformat() if j.created_at else None, "created_at": j.created_at.isoformat() if j.created_at else None,
}) })
@@ -182,13 +290,12 @@ async def get_job(job_id: int):
"schedule_enabled": job.schedule_enabled, "schedule_once": job.schedule_once, "schedule_enabled": job.schedule_enabled, "schedule_once": job.schedule_once,
"schedule_date": job.schedule_date, "schedule_days": job.schedule_days, "schedule_date": job.schedule_date, "schedule_days": job.schedule_days,
"schedule_start": job.schedule_start, "schedule_stop": job.schedule_stop, "schedule_start": job.schedule_start, "schedule_stop": job.schedule_stop,
"schedule_end_date": job.schedule_end_date, "description": job.description, "schedule_end_date": job.schedule_end_date,
"pre_buffer": job.pre_buffer or 0, "post_buffer": job.post_buffer or 0,
"description": job.description,
"ntfy_enabled": job.ntfy_enabled, "ntfy_enabled": job.ntfy_enabled,
"plik_enabled": job.plik_enabled, "delete_after_upload": job.delete_after_upload, "plik_enabled": job.plik_enabled, "delete_after_upload": job.delete_after_upload,
"metadata_monitor_enabled": job.metadata_monitor_enabled, "station_id": job.station_id,
"metadata_pattern": job.metadata_pattern,
"metadata_grace_period": job.metadata_grace_period,
"metadata_poll_interval": job.metadata_poll_interval,
} }
finally: finally:
session.close() session.close()
@@ -254,10 +361,6 @@ async def start_job_recording(job_id: int):
"max_duration": duration, "extra_ffmpeg_args": job.extra_ffmpeg_args, "max_duration": duration, "extra_ffmpeg_args": job.extra_ffmpeg_args,
"segment_duration": job.segment_duration, "segment_duration": job.segment_duration,
"description": job.description, "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, "ntfy_enabled": job.ntfy_enabled,
"plik_enabled": job.plik_enabled, "plik_enabled": job.plik_enabled,
"delete_after_upload": job.delete_after_upload, "delete_after_upload": job.delete_after_upload,
@@ -310,6 +413,9 @@ async def get_recording(rec_id: int):
@app.post("/api/recordings/{rec_id}/stop") @app.post("/api/recordings/{rec_id}/stop")
async def stop_recording(rec_id: int): async def stop_recording(rec_id: int):
status = recorder.get_status(rec_id)
if status and status.get("job_id"):
scheduler.suppress_current_occurrence(status["job_id"])
recorder.stop_recording(rec_id) recorder.stop_recording(rec_id)
return {"message": "Aufnahme gestoppt"} return {"message": "Aufnahme gestoppt"}
+4
View File
@@ -26,6 +26,10 @@ DEFAULT_CONFIG = {
"api_key": "", "api_key": "",
"ttl": "30d", "ttl": "30d",
}, },
"schedule": {
"default_pre_buffer": 5,
"default_post_buffer": 5,
},
"logging": { "logging": {
"level": "INFO", "level": "INFO",
}, },
+36 -5
View File
@@ -1,5 +1,5 @@
import datetime import datetime
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Text from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Text, text
from sqlalchemy.orm import declarative_base, sessionmaker from sqlalchemy.orm import declarative_base, sessionmaker
Base = declarative_base() Base = declarative_base()
@@ -25,6 +25,9 @@ class Job(Base):
schedule_stop = Column(String(5), default="") schedule_stop = Column(String(5), default="")
schedule_end_date = Column(String(10), default="") schedule_end_date = Column(String(10), default="")
pre_buffer = Column(Integer, default=0)
post_buffer = Column(Integer, default=0)
description = Column(Text, default="") description = Column(Text, default="")
plik_enabled = Column(Boolean, default=False) plik_enabled = Column(Boolean, default=False)
@@ -32,15 +35,24 @@ class Job(Base):
ntfy_enabled = Column(Boolean, default=False) ntfy_enabled = Column(Boolean, default=False)
metadata_monitor_enabled = Column(Boolean, default=False) station_id = Column(Integer, nullable=True)
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) created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
class Station(Base):
__tablename__ = "stations"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(200), nullable=False)
stream_url = Column(String(1000), nullable=False)
stream_type = Column(String(20), default="auto")
description = Column(Text, default="")
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
class Recording(Base): class Recording(Base):
__tablename__ = "recordings" __tablename__ = "recordings"
@@ -59,6 +71,8 @@ class Recording(Base):
segment_mode = Column(Boolean, default=False) segment_mode = Column(Boolean, default=False)
is_scheduled = Column(Boolean, default=False) is_scheduled = Column(Boolean, default=False)
error_message = Column(Text, nullable=True) error_message = Column(Text, nullable=True)
file_size = Column(Integer, nullable=True)
plik_url = Column(String(1000), nullable=True)
class LogEntry(Base): class LogEntry(Base):
@@ -80,6 +94,23 @@ def init_db(db_path: str):
_engine = create_engine(f"sqlite:///{db_path}", connect_args={"check_same_thread": False}) _engine = create_engine(f"sqlite:///{db_path}", connect_args={"check_same_thread": False})
Base.metadata.create_all(_engine) Base.metadata.create_all(_engine)
_SessionLocal = sessionmaker(bind=_engine) _SessionLocal = sessionmaker(bind=_engine)
_migrate(_engine)
def _migrate(engine):
with engine.connect() as conn:
for stmt in [
"ALTER TABLE recordings ADD COLUMN file_size INTEGER",
"ALTER TABLE jobs ADD COLUMN station_id INTEGER",
"ALTER TABLE jobs ADD COLUMN pre_buffer INTEGER DEFAULT 0",
"ALTER TABLE jobs ADD COLUMN post_buffer INTEGER DEFAULT 0",
"ALTER TABLE recordings ADD COLUMN plik_url VARCHAR(1000)",
]:
try:
conn.execute(text(stmt))
conn.commit()
except Exception:
pass
def get_session(): def get_session():
-138
View File
@@ -1,138 +0,0 @@
"""
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)
+46 -27
View File
@@ -5,10 +5,10 @@ import os
import re import re
import subprocess import subprocess
import threading import threading
from typing import Optional, Callable from typing import Callable
def _parse_ttl_seconds(ttl: str) -> Optional[int]: def _parse_ttl_seconds(ttl: str) -> int | None:
if not ttl: if not ttl:
return None return None
m = re.match(r"^(\d+)d$", ttl) m = re.match(r"^(\d+)d$", ttl)
@@ -28,17 +28,19 @@ def upload_file(
plik_url: str, plik_url: str,
api_key: str = "", api_key: str = "",
ttl: str = "30d", ttl: str = "30d",
) -> Optional[dict]: ) -> dict | None:
"""Upload a file to Plik. Returns dict with 'browser_url' and 'download_url', or None on failure.""" """Upload a file to Plik. Returns dict with 'browser_url', 'download_url', or {'error': reason} on failure."""
if not plik_url or not os.path.isfile(file_path): if not plik_url:
return None return {"error": "Keine Plik-URL konfiguriert"}
if not os.path.isfile(file_path):
return {"error": f"Datei nicht gefunden: {file_path}"}
if os.path.getsize(file_path) == 0: if os.path.getsize(file_path) == 0:
return None return {"error": "Datei ist leer"}
plik_base = plik_url.rstrip("/") plik_base = plik_url.rstrip("/")
filename = os.path.basename(file_path) filename = os.path.basename(file_path)
cmd = ["curl", "-sS", "--max-time", "7200"] cmd = ["curl", "-sS", "--fail-with-body", "--max-time", "7200"]
if api_key: if api_key:
cmd += ["-H", f"X-PlikToken: {api_key}"] cmd += ["-H", f"X-PlikToken: {api_key}"]
@@ -53,13 +55,18 @@ def upload_file(
try: try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=7200, stdin=subprocess.DEVNULL) result = subprocess.run(cmd, capture_output=True, text=True, timeout=7200, stdin=subprocess.DEVNULL)
if result.returncode != 0: if result.returncode != 0:
return None err = result.stderr.strip() or result.stdout.strip() or f"curl exit code {result.returncode}"
return {"error": f"curl-Fehler: {err[:500]}"}
try: try:
data = json.loads(result.stdout) data = json.loads(result.stdout)
if "error" in data:
return {"error": f"Plik-Server: {data['error']}"}
upload_id = data.get("id", "") upload_id = data.get("id", "")
if not upload_id: if not upload_id:
return None return {"error": f"Keine Upload-ID in Antwort: {result.stdout[:300]}"}
browser_url = f"{plik_base}/#/?id={upload_id}" browser_url = f"{plik_base}/#/?id={upload_id}"
@@ -83,9 +90,11 @@ def upload_file(
if uid: if uid:
return {"browser_url": f"{plik_base}/#/?id={uid}", "download_url": download_url} return {"browser_url": f"{plik_base}/#/?id={uid}", "download_url": download_url}
return {"browser_url": download_url, "download_url": download_url} return {"browser_url": download_url, "download_url": download_url}
return None return {"error": f"Antwort nicht lesbar: {result.stdout[:300]}"}
except Exception: except subprocess.TimeoutExpired:
return None return {"error": "Upload-Timeout (max. 2h)"}
except Exception as e:
return {"error": str(e)}
class PlikUploader: class PlikUploader:
@@ -95,7 +104,8 @@ class PlikUploader:
self._notify = notify_fn self._notify = notify_fn
def upload_recording(self, output_dir: str, output_file: str, job_name: str, def upload_recording(self, output_dir: str, output_file: str, job_name: str,
segment_mode: bool, delete_after: bool, ntfy_enabled: bool): segment_mode: bool, delete_after: bool, ntfy_enabled: bool,
save_url_fn: Callable = None):
plik_url = self.config.get("plik", "url", default="") plik_url = self.config.get("plik", "url", default="")
if not plik_url: if not plik_url:
self._log("WARN", f"Plik-Upload übersprungen: Keine URL konfiguriert [{job_name}]", job_name) self._log("WARN", f"Plik-Upload übersprungen: Keine URL konfiguriert [{job_name}]", job_name)
@@ -107,15 +117,15 @@ class PlikUploader:
def _do_upload(): def _do_upload():
try: try:
if segment_mode: if segment_mode:
self._upload_segments(output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled) self._upload_segments(output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn)
else: else:
self._upload_single(output_file, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled) self._upload_single(output_file, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn)
except Exception as e: except Exception as e:
self._log("ERROR", f"Plik-Upload Fehler: {e} [{job_name}]", job_name) self._log("ERROR", f"Plik-Upload Fehler: {e} [{job_name}]", job_name)
threading.Thread(target=_do_upload, daemon=True).start() threading.Thread(target=_do_upload, daemon=True).start()
def _upload_single(self, file_path, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled): def _upload_single(self, file_path, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn=None):
if not os.path.isfile(file_path) or os.path.getsize(file_path) == 0: 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) self._log("WARN", f"Plik-Upload übersprungen: Datei fehlt oder leer [{job_name}]", job_name)
return return
@@ -123,19 +133,23 @@ class PlikUploader:
self._log("INFO", f"Plik-Upload: {os.path.basename(file_path)} [{job_name}]", job_name) 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) result = upload_file(file_path, plik_url, api_key, ttl)
if result: if result and "error" in result:
self._log("ERROR", f"Plik-Upload fehlgeschlagen: {result['error']} [{job_name}]", job_name)
elif result and result.get("browser_url"):
self._log("INFO", f"Plik-Upload abgeschlossen: {result['browser_url']} [{job_name}]", job_name) self._log("INFO", f"Plik-Upload abgeschlossen: {result['browser_url']} [{job_name}]", job_name)
if result.get("download_url"): if result.get("download_url"):
self._log("INFO", f"Plik-Download: {result['download_url']} [{job_name}]", job_name) self._log("INFO", f"Plik-Download: {result['download_url']} [{job_name}]", job_name)
if save_url_fn:
save_url_fn(result["browser_url"])
if self._notify: if self._notify:
self._notify("upload", "Plik-Upload", f"{job_name}: {result['browser_url']}", ntfy_enabled) self._notify("upload", "Plik-Upload", f"{job_name}: {result['browser_url']}", ntfy_enabled)
if delete_after: if delete_after:
os.remove(file_path) os.remove(file_path)
self._log("INFO", f"Lokale Datei gelöscht: {file_path} [{job_name}]", job_name) self._log("INFO", f"Lokale Datei gelöscht: {file_path} [{job_name}]", job_name)
else: else:
self._log("ERROR", f"Plik-Upload fehlgeschlagen [{job_name}]", job_name) self._log("ERROR", f"Plik-Upload fehlgeschlagen (unbekannter Fehler) [{job_name}]", job_name)
def _upload_segments(self, output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled): def _upload_segments(self, output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn=None):
if not os.path.isdir(output_dir): if not os.path.isdir(output_dir):
return return
@@ -151,7 +165,9 @@ class PlikUploader:
continue continue
self._log("INFO", f"Plik Segment-Upload: {fname} [{job_name}]", job_name) self._log("INFO", f"Plik Segment-Upload: {fname} [{job_name}]", job_name)
result = upload_file(fpath, plik_url, api_key, ttl) result = upload_file(fpath, plik_url, api_key, ttl)
if result: if result and "error" in result:
self._log("ERROR", f"Plik Segment-Upload fehlgeschlagen: {fname} - {result['error']} [{job_name}]", job_name)
elif result and result.get("browser_url"):
last_browser_url = result["browser_url"] last_browser_url = result["browser_url"]
self._log("INFO", f"Plik Segment hochgeladen: {last_browser_url} [{job_name}]", job_name) self._log("INFO", f"Plik Segment hochgeladen: {last_browser_url} [{job_name}]", job_name)
uploaded += 1 uploaded += 1
@@ -159,10 +175,13 @@ class PlikUploader:
os.remove(fpath) os.remove(fpath)
self._log("INFO", f"Segment gelöscht: {fname} [{job_name}]", job_name) self._log("INFO", f"Segment gelöscht: {fname} [{job_name}]", job_name)
else: else:
self._log("ERROR", f"Plik Segment-Upload fehlgeschlagen: {fname} [{job_name}]", job_name) self._log("ERROR", f"Plik Segment-Upload fehlgeschlagen: {fname} (unbekannter Fehler) [{job_name}]", job_name)
if uploaded > 0 and self._notify: if uploaded > 0:
msg = f"{job_name}: {uploaded} Segment(e) hochgeladen" if save_url_fn and last_browser_url:
if last_browser_url: save_url_fn(last_browser_url)
msg += f"\n{last_browser_url}" if self._notify:
self._notify("upload", "Plik-Upload", msg, ntfy_enabled) 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)
+121 -107
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import datetime import datetime
import glob as globmod
import os import os
import re import re
import signal import signal
@@ -64,19 +65,10 @@ class RecordingManager:
self._processes: dict[int, subprocess.Popen] = {} self._processes: dict[int, subprocess.Popen] = {}
self._stop_events: dict[int, threading.Event] = {} self._stop_events: dict[int, threading.Event] = {}
self._threads: dict[int, threading.Thread] = {} self._threads: dict[int, threading.Thread] = {}
self._metadata_monitor = None
self._ntfy = None self._ntfy = None
self._plik = None self._plik = None
self._rec_options: dict[int, dict] = {} self._rec_options: dict[int, dict] = {}
self._output_files: dict[int, list[str]] = {}
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): def _get_ntfy(self):
if self._ntfy is None: if self._ntfy is None:
@@ -124,10 +116,6 @@ class RecordingManager:
segment_duration: Optional[int] = None, segment_duration: Optional[int] = None,
is_scheduled: bool = False, is_scheduled: bool = False,
description: str = "", description: str = "",
metadata_monitor_enabled: bool = False,
metadata_pattern: str = "",
metadata_grace_period: int = 300,
metadata_poll_interval: int = 30,
ntfy_enabled: bool = False, ntfy_enabled: bool = False,
plik_enabled: bool = False, plik_enabled: bool = False,
delete_after_upload: bool = False, delete_after_upload: bool = False,
@@ -173,6 +161,15 @@ class RecordingManager:
finally: finally:
session.close() session.close()
self._rec_options[rec_id] = {
"ntfy_enabled": ntfy_enabled,
"plik_enabled": plik_enabled,
"delete_after_upload": delete_after_upload,
"segment_mode": segment_mode,
"description": description,
}
self._output_files[rec_id] = [output_file]
stop_event = threading.Event() stop_event = threading.Event()
self._stop_events[rec_id] = stop_event self._stop_events[rec_id] = stop_event
@@ -185,27 +182,9 @@ class RecordingManager:
self._threads[rec_id] = thread self._threads[rec_id] = thread
thread.start() 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._log("INFO", f"Aufnahme gestartet: {name}", name)
self._notify("start", "Aufnahme gestartet", name, rec_id) 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} return {"recording_id": rec_id, "output_dir": output_dir, "output_file": output_file}
def _recording_loop( def _recording_loop(
@@ -225,74 +204,90 @@ class RecordingManager:
retry_delay = self.config.get("recording", "retry_delay", default=5) retry_delay = self.config.get("recording", "retry_delay", default=5)
retry = 0 retry = 0
while not stop_event.is_set(): try:
cmd = self._build_ffmpeg_cmd( while not stop_event.is_set():
stream_url, stream_type, output_file, cmd = self._build_ffmpeg_cmd(
extra_ffmpeg_args, segment_mode, segment_duration, 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 try:
self._update_recording(rec_id, status="running", pid=process.pid) 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),
file_size=self._finalize_file_size(rec_id, segment_mode))
self._log("ERROR", f"ffmpeg konnte nicht gestartet werden: {e}", job_name)
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: {e}", rec_id, priority="high")
return
process.wait() self._processes[rec_id] = process
retcode = process.returncode self._update_recording(rec_id, status="running", pid=process.pid)
log_fh.close()
self._processes.pop(rec_id, None)
if stop_event.is_set() or retcode == 0: process.wait()
status = "completed" if retcode == 0 else "stopped" retcode = process.returncode
status_de = "abgeschlossen" if status == "completed" else "gestoppt" log_fh.close()
self._update_recording(rec_id, status=status) self._processes.pop(rec_id, None)
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): if stop_event.is_set() or retcode == 0:
self._update_recording(rec_id, status="stopped") status = "completed" if retcode == 0 else "stopped"
self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name) status_de = "abgeschlossen" if status == "completed" else "gestoppt"
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id) self._update_recording(rec_id, status=status,
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode) file_size=self._finalize_file_size(rec_id, segment_mode))
return 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
retry += 1 if retcode in (-2, 130, 143, 255):
if max_retries > 0 and retry >= max_retries: self._update_recording(rec_id, status="stopped",
self._update_recording(rec_id, status="error", file_size=self._finalize_file_size(rec_id, segment_mode))
error_message=f"Max retries ({max_retries}) reached, exit code {retcode}") self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
self._log("ERROR", f"Max. Versuche ({max_retries}) erreicht: {job_name}", job_name) self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: Max. Versuche erreicht", rec_id, priority="high") self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
return return
self._log("WARN", f"Verbindung verloren (Code: {retcode}), Reconnect in {retry_delay}s... [{job_name}]", job_name) retry += 1
self._notify("error", "Stream abgerissen", f"{job_name}: Reconnect in {retry_delay}s...", rec_id) if max_retries > 0 and retry >= max_retries:
if stop_event.wait(timeout=retry_delay): self._update_recording(rec_id, status="error",
self._update_recording(rec_id, status="stopped") error_message=f"Max retries ({max_retries}) reached, exit code {retcode}",
return file_size=self._finalize_file_size(rec_id, segment_mode))
self._log("ERROR", f"Max. Versuche ({max_retries}) erreicht: {job_name}", job_name)
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: Max. Versuche erreicht", rec_id, priority="high")
return
sname = safe_name(job_name) self._log("WARN", f"Verbindung verloren (Code: {retcode}), Reconnect in {retry_delay}s... [{job_name}]", job_name)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") self._notify("error", "Stream abgerissen", f"{job_name}: Reconnect in {retry_delay}s...", rec_id)
ext = Path(output_file).suffix.lstrip(".") if stop_event.wait(timeout=retry_delay):
if segment_mode: self._update_recording(rec_id, status="stopped",
output_file = os.path.join(output_dir, f"{sname}_{ts}_seg%03d.{ext}") file_size=self._finalize_file_size(rec_id, segment_mode))
else: self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
output_file = os.path.join(output_dir, f"{sname}_{ts}.{ext}") self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
return
self._update_recording(rec_id, status="stopped") sname = safe_name(job_name)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
ext = Path(output_file).suffix.lstrip(".")
if segment_mode:
output_file = os.path.join(output_dir, f"{sname}_{ts}_seg%03d.{ext}")
else:
output_file = os.path.join(output_dir, f"{sname}_{ts}.{ext}")
self._output_files.setdefault(rec_id, []).append(output_file)
self._update_recording(rec_id, status="stopped",
file_size=self._finalize_file_size(rec_id, segment_mode))
self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
finally:
self._cleanup(rec_id)
def _build_ffmpeg_cmd(self, url, stream_type, output_file, extra_args, segment_mode, segment_duration): def _build_ffmpeg_cmd(self, url, stream_type, output_file, extra_args, segment_mode, segment_duration):
cmd = ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "warning"] cmd = ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "warning"]
@@ -312,9 +307,6 @@ class RecordingManager:
return cmd return cmd
def stop_recording(self, rec_id: int) -> bool: 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) stop_event = self._stop_events.get(rec_id)
if stop_event: if stop_event:
stop_event.set() stop_event.set()
@@ -330,9 +322,10 @@ class RecordingManager:
except (ProcessLookupError, OSError): except (ProcessLookupError, OSError):
pass pass
self._update_recording(rec_id, status="stopped") if not process and rec_id not in self._threads:
self._log("INFO", f"Aufnahme gestoppt (ID: {rec_id})") self._update_recording(rec_id, status="stopped")
self._cleanup(rec_id) self._log("INFO", f"Aufnahme gestoppt (ID: {rec_id})")
self._cleanup(rec_id)
return True return True
def extend_recording(self, rec_id: int, extra_seconds: int) -> bool: def extend_recording(self, rec_id: int, extra_seconds: int) -> bool:
@@ -433,16 +426,11 @@ class RecordingManager:
total_size = 0 total_size = 0
segment_count = 0 segment_count = 0
if rec.output_dir and os.path.isdir(rec.output_dir): if rec.file_size is not None:
for f in os.listdir(rec.output_dir): total_size = rec.file_size
if f.startswith("."): else:
continue files = self._output_files.get(rec.id, [rec.output_file] if rec.output_file else [])
fp = os.path.join(rec.output_dir, f) total_size, segment_count = self._calc_recording_size(files, rec.segment_mode)
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 remaining = None
if rec.scheduled_stop and rec.status == "running": if rec.scheduled_stop and rec.status == "running":
@@ -472,8 +460,32 @@ class RecordingManager:
"segment_count": segment_count if rec.segment_mode else None, "segment_count": segment_count if rec.segment_mode else None,
"remaining": remaining, "remaining": remaining,
"remaining_display": format_elapsed(remaining) if remaining is not None else None, "remaining_display": format_elapsed(remaining) if remaining is not None else None,
"plik_url": rec.plik_url,
} }
def _calc_recording_size(self, output_files: list[str], segment_mode: bool) -> tuple[int, int]:
total_size = 0
segment_count = 0
for of in output_files:
if segment_mode:
pattern = of.replace('%03d', '*')
for f in globmod.glob(pattern):
if os.path.isfile(f):
total_size += os.path.getsize(f)
segment_count += 1
else:
if os.path.isfile(of):
total_size += os.path.getsize(of)
segment_count += 1
return total_size, segment_count
def _finalize_file_size(self, rec_id: int, segment_mode: bool) -> int:
files = self._output_files.get(rec_id, [])
if not files:
return 0
total_size, _ = self._calc_recording_size(files, segment_mode)
return total_size
def _update_recording(self, rec_id: int, **kwargs): def _update_recording(self, rec_id: int, **kwargs):
session = get_session() session = get_session()
try: try:
@@ -498,6 +510,7 @@ class RecordingManager:
segment_mode=segment_mode, segment_mode=segment_mode,
delete_after=opts.get("delete_after_upload", False), delete_after=opts.get("delete_after_upload", False),
ntfy_enabled=opts.get("ntfy_enabled", False), ntfy_enabled=opts.get("ntfy_enabled", False),
save_url_fn=lambda url: self._update_recording(rec_id, plik_url=url),
) )
def _cleanup(self, rec_id: int): def _cleanup(self, rec_id: int):
@@ -505,3 +518,4 @@ class RecordingManager:
self._stop_events.pop(rec_id, None) self._stop_events.pop(rec_id, None)
self._threads.pop(rec_id, None) self._threads.pop(rec_id, None)
self._rec_options.pop(rec_id, None) self._rec_options.pop(rec_id, None)
self._output_files.pop(rec_id, None)
+179 -54
View File
@@ -1,5 +1,6 @@
import datetime import datetime
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import or_
from models import Job, Recording, get_session from models import Job, Recording, get_session
DAY_MAP = { DAY_MAP = {
@@ -53,6 +54,23 @@ def _in_time_window(now_time: str, start: str, stop: str) -> bool:
return now_min >= start_min or now_min < stop_min return now_min >= start_min or now_min < stop_min
def _apply_buffers(job: dict) -> dict:
eff = dict(job)
pre = job.get("pre_buffer", 0) or 0
post = job.get("post_buffer", 0) or 0
if pre > 0 and eff.get("schedule_start"):
m = _time_to_minutes(eff["schedule_start"]) - pre
if m < 0:
m += 1440
eff["schedule_start"] = f"{m // 60:02d}:{m % 60:02d}"
if post > 0 and eff.get("schedule_stop"):
m = _time_to_minutes(eff["schedule_stop"]) + post
if m >= 1440:
m -= 1440
eff["schedule_stop"] = f"{m // 60:02d}:{m % 60:02d}"
return eff
class StreamScheduler: class StreamScheduler:
def __init__(self, recorder): def __init__(self, recorder):
self.recorder = recorder self.recorder = recorder
@@ -75,6 +93,136 @@ class StreamScheduler:
today = job.schedule_date or today today = job.schedule_date or today
return f"{job.id}|{job.schedule_once}|{today}|{job.schedule_start}" return f"{job.id}|{job.schedule_once}|{today}|{job.schedule_start}"
def _original_occ_key(self, job: dict, now: datetime.datetime | None = None) -> str:
now = now or datetime.datetime.now()
today_date = now.strftime("%Y-%m-%d")
date = today_date if not job["schedule_once"] else job["schedule_date"]
return f"{job['id']}|{job['schedule_once']}|{date}|{job['schedule_start']}"
def _schedule_state(self, job: dict, now: datetime.datetime | None = None) -> dict:
now = now or datetime.datetime.now()
today_num = now.isoweekday()
today_date = now.strftime("%Y-%m-%d")
now_time = now.strftime("%H:%M")
occ_key = (
f"{job['id']}|{job['schedule_once']}|"
f"{today_date if not job['schedule_once'] else job['schedule_date']}|"
f"{job['schedule_start']}"
)
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
elif job.get("schedule_stop"):
start_min = _time_to_minutes(job["schedule_start"])
stop_min = _time_to_minutes(job["schedule_stop"])
if stop_min <= start_min and stop_min > 0:
now_min = _time_to_minutes(now_time)
if now_min < stop_min:
yesterday = now - datetime.timedelta(days=1)
was_yesterday = False
if job["schedule_once"]:
was_yesterday = job["schedule_date"] == yesterday.strftime("%Y-%m-%d")
else:
was_yesterday = _matches_day(yesterday.isoweekday(), job["schedule_days"] or "*")
if was_yesterday:
is_cross_midnight = True
in_window = True
return {
"occ_key": occ_key,
"in_window": in_window,
"multi_day": multi_day,
"is_cross_midnight": is_cross_midnight,
}
def suppress_current_occurrence(self, job_id: int) -> bool:
session = get_session()
try:
j = session.query(Job).filter_by(id=job_id).first()
if not j or not j.schedule_enabled or not j.schedule_start:
return False
job = {
"id": j.id,
"schedule_once": j.schedule_once,
"schedule_date": j.schedule_date,
"schedule_days": j.schedule_days,
"schedule_start": j.schedule_start,
"schedule_stop": j.schedule_stop,
"schedule_end_date": j.schedule_end_date,
"pre_buffer": j.pre_buffer or 0,
"post_buffer": j.post_buffer or 0,
}
finally:
session.close()
occ_key = self._original_occ_key(job)
eff_job = _apply_buffers(job)
state = self._schedule_state(eff_job)
if not state["in_window"]:
return False
self._started_keys.add(occ_key)
return True
def _occurrence_start_utc(self, job: dict, now: datetime.datetime) -> datetime.datetime | None:
date_text = job["schedule_date"] if job["schedule_once"] else now.strftime("%Y-%m-%d")
try:
local_start = datetime.datetime.strptime(
f"{date_text} {job['schedule_start']}",
"%Y-%m-%d %H:%M",
)
except (TypeError, ValueError):
return None
local_utc_offset = datetime.datetime.now() - datetime.datetime.utcnow()
return local_start - local_utc_offset
def _has_recording_for_occurrence(self, job: dict, now: datetime.datetime) -> bool:
occurrence_start = self._occurrence_start_utc(job, now)
if occurrence_start is None:
return False
session = get_session()
try:
return session.query(Recording).filter(
Recording.job_id == job["id"],
or_(
Recording.started_at >= occurrence_start,
Recording.stopped_at >= occurrence_start,
),
).first() is not None
finally:
session.close()
def _is_job_recording(self, job_id: int) -> bool: def _is_job_recording(self, job_id: int) -> bool:
session = get_session() session = get_session()
try: try:
@@ -85,7 +233,7 @@ class StreamScheduler:
finally: finally:
session.close() session.close()
def _get_active_scheduled_recording_for_job(self, job_id: int): def _get_active_scheduled_recording_for_job(self, job_id: int, *, respect_stop: bool = False):
session = get_session() session = get_session()
try: try:
rec = session.query(Recording).filter( rec = session.query(Recording).filter(
@@ -93,15 +241,16 @@ class StreamScheduler:
Recording.status.in_(["running", "starting"]), Recording.status.in_(["running", "starting"]),
Recording.is_scheduled == True, Recording.is_scheduled == True,
).first() ).first()
return rec.id if rec else None if not rec:
return None
if respect_stop and rec.scheduled_stop and rec.scheduled_stop > datetime.datetime.utcnow():
return None
return rec.id
finally: finally:
session.close() session.close()
def _tick(self): def _tick(self):
now = datetime.datetime.now() now = datetime.datetime.now()
today_num = now.isoweekday()
today_date = now.strftime("%Y-%m-%d")
now_time = now.strftime("%H:%M")
session = get_session() session = get_session()
try: try:
@@ -116,11 +265,8 @@ class StreamScheduler:
"schedule_once": j.schedule_once, "schedule_date": j.schedule_date, "schedule_once": j.schedule_once, "schedule_date": j.schedule_date,
"schedule_days": j.schedule_days, "schedule_start": j.schedule_start, "schedule_days": j.schedule_days, "schedule_start": j.schedule_start,
"schedule_stop": j.schedule_stop, "schedule_end_date": j.schedule_end_date, "schedule_stop": j.schedule_stop, "schedule_end_date": j.schedule_end_date,
"pre_buffer": j.pre_buffer or 0, "post_buffer": j.post_buffer or 0,
"description": j.description, "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, "ntfy_enabled": j.ntfy_enabled,
"plik_enabled": j.plik_enabled, "plik_enabled": j.plik_enabled,
"delete_after_upload": j.delete_after_upload, "delete_after_upload": j.delete_after_upload,
@@ -132,52 +278,28 @@ class StreamScheduler:
if not job["schedule_start"]: if not job["schedule_start"]:
continue continue
is_today = False occ_key = self._original_occ_key(job, now)
multi_day = bool(job.get("schedule_end_date") and job["schedule_once"]) eff_job = _apply_buffers(job)
state = self._schedule_state(eff_job, now)
if job["schedule_once"]: in_window = state["in_window"]
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 in_window:
if occ_key not in self._started_keys and not self._is_job_recording(job["id"]): if (
occ_key not in self._started_keys
and not self._is_job_recording(job["id"])
and not self._has_recording_for_occurrence(job, now)
):
duration = job["max_duration"] duration = job["max_duration"]
if not duration and job["schedule_stop"]: if not duration and eff_job["schedule_stop"]:
if multi_day: if state["multi_day"]:
start_dt = datetime.datetime.strptime( start_dt = datetime.datetime.strptime(
f"{job['schedule_date']} {job['schedule_start']}", "%Y-%m-%d %H:%M") f"{job['schedule_date']} {eff_job['schedule_start']}", "%Y-%m-%d %H:%M")
end_dt = datetime.datetime.strptime( end_dt = datetime.datetime.strptime(
f"{job['schedule_end_date']} {job['schedule_stop']}", "%Y-%m-%d %H:%M") f"{job['schedule_end_date']} {eff_job['schedule_stop']}", "%Y-%m-%d %H:%M")
duration = max(0, int((end_dt - start_dt).total_seconds())) duration = max(0, int((end_dt - start_dt).total_seconds()))
else: else:
start_min = _time_to_minutes(job["schedule_start"]) start_min = _time_to_minutes(eff_job["schedule_start"])
stop_min = _time_to_minutes(job["schedule_stop"]) stop_min = _time_to_minutes(eff_job["schedule_stop"])
if stop_min > start_min: if stop_min > start_min:
duration = (stop_min - start_min) * 60 duration = (stop_min - start_min) * 60
else: else:
@@ -194,18 +316,21 @@ class StreamScheduler:
segment_duration=job["segment_duration"], segment_duration=job["segment_duration"],
is_scheduled=True, is_scheduled=True,
description=job.get("description", ""), 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), ntfy_enabled=job.get("ntfy_enabled", False),
plik_enabled=job.get("plik_enabled", False), plik_enabled=job.get("plik_enabled", False),
delete_after_upload=job.get("delete_after_upload", False), delete_after_upload=job.get("delete_after_upload", False),
) )
self._started_keys.add(occ_key) self._started_keys.add(occ_key)
else: else:
if not multi_day and not is_cross_midnight and job["schedule_stop"] and self._is_job_recording(job["id"]): if (
rec_id = self._get_active_scheduled_recording_for_job(job["id"]) not state["multi_day"]
and not state["is_cross_midnight"]
and eff_job["schedule_stop"]
and self._is_job_recording(job["id"])
):
rec_id = self._get_active_scheduled_recording_for_job(
job["id"], respect_stop=True,
)
if rec_id: if rec_id:
self.recorder.stop_recording(rec_id) self.recorder.stop_recording(rec_id)
self._started_keys.discard(occ_key) self._started_keys.discard(occ_key)
@@ -38,5 +38,10 @@ plik:
api_key: "" api_key: ""
ttl: "30d" ttl: "30d"
# Zeitplan-Puffer (Minuten) Standardwerte für neue Jobs
schedule:
default_pre_buffer: 5
default_post_buffer: 5
logging: logging:
level: INFO level: INFO
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+506 -65
View File
@@ -3,7 +3,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>stream-recorder</title> <title>Stream-Recorder</title>
<link rel="icon" type="image/png" href="/favicon.png">
<style> <style>
:root { :root {
--bg: #0f1117; --bg: #0f1117;
@@ -30,6 +31,7 @@
color: var(--text); color: var(--text);
line-height: 1.5; line-height: 1.5;
min-height: 100vh; min-height: 100vh;
padding-bottom: 52px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -53,7 +55,15 @@
gap: 10px; gap: 10px;
} }
header h1 .rec-dot { header h1 .header-logo {
width: 28px; height: 28px;
border-radius: 50%;
transition: filter 0.3s;
}
.rec-dot {
display: inline-block;
width: 10px; height: 10px; width: 10px; height: 10px;
background: var(--recording); background: var(--recording);
border-radius: 50%; border-radius: 50%;
@@ -306,13 +316,49 @@
.remaining.low { color: var(--warning); } .remaining.low { color: var(--warning); }
.remaining.critical { color: var(--error); } .remaining.critical { color: var(--error); }
/* Burger menu */
.burger {
display: none;
background: none;
border: none;
color: var(--text);
font-size: 1.5rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.15s;
line-height: 1;
}
.burger:hover { background: var(--bg-card); }
/* Responsive */ /* Responsive */
@media (max-width: 640px) { @media (max-width: 640px) {
.app { padding: 10px; } .app { padding: 10px; }
.form-grid { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; }
.tabs { flex-wrap: wrap; } .burger { display: block; }
.tab { padding: 6px 12px; font-size: 0.8rem; } .tabs {
header { flex-direction: column; gap: 12px; align-items: flex-start; } display: none;
flex-direction: column;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0 0 var(--radius) var(--radius);
padding: 8px;
z-index: 90;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.tabs.open { display: flex; }
.tab { padding: 10px 16px; font-size: 0.9rem; text-align: left; border-radius: 6px; }
header {
flex-direction: row;
align-items: center;
justify-content: space-between;
position: relative;
}
header h1 { font-size: 1.1rem; } header h1 { font-size: 1.1rem; }
.card { padding: 12px; } .card { padding: 12px; }
.card-header { flex-direction: column; align-items: flex-start; gap: 8px; } .card-header { flex-direction: column; align-items: flex-start; gap: 8px; }
@@ -338,15 +384,62 @@
padding-bottom: 8px; padding-bottom: 8px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.card-recent { cursor: pointer; }
.card-recent:hover { border-color: var(--accent); }
.recent-meta { display: flex; gap: 0; font-size: 0.8rem; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.recent-meta .rm-dur { min-width: 100px; text-align: right; }
.recent-meta .rm-size { min-width: 90px; text-align: right; }
.recent-meta .rm-date { min-width: 150px; text-align: right; }
@media (max-width: 640px) {
.recent-meta { flex-wrap: wrap; gap: 4px; }
.recent-meta .rm-dur, .recent-meta .rm-size, .recent-meta .rm-date { min-width: auto; text-align: left; }
}
.rec-detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px; }
.rec-detail-item { display: flex; flex-direction: column; gap: 2px; }
.rec-detail-item.full { grid-column: 1 / -1; }
.rec-detail-label { font-size: 0.7rem; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.04em; }
.rec-detail-value { font-size: 0.9rem; color: var(--text); word-break: break-all; }
.rec-detail-value a { color: var(--accent); text-decoration: none; }
.rec-detail-value a:hover { text-decoration: underline; }
.rec-plik-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; background: rgba(108,123,255,0.1); border: 1px solid var(--accent); border-radius: 6px; color: var(--accent); font-weight: 500; font-size: 0.85rem; text-decoration: none; transition: background 0.15s; }
.rec-plik-link:hover { background: rgba(108,123,255,0.2); text-decoration: none; }
.rec-detail-divider { grid-column: 1 / -1; border: none; border-top: 1px solid var(--border); margin: 4px 0; }
@media (max-width: 640px) { .rec-detail-grid { grid-template-columns: 1fr; } }
.site-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
text-align: center;
padding: 12px 16px;
background: rgba(15, 17, 23, 0.96);
border-top: 1px solid var(--border);
color: var(--text-dim);
font-size: 0.75rem;
}
.site-footer a {
color: var(--text-dim);
}
.site-footer a:last-child {
color: var(--accent);
}
</style> </style>
</head> </head>
<body> <body>
<div class="app"> <div class="app">
<header> <header>
<h1><span class="rec-dot" id="recDot"></span> stream-recorder</h1> <h1><img src="/logo.png" alt="Logo" class="header-logo" id="headerLogo"> Stream-Recorder</h1>
<div class="tabs"> <button class="burger" id="burgerBtn" onclick="toggleBurger()" aria-label="Menü">&#9776;</button>
<div class="tabs" id="navTabs">
<button class="tab active" onclick="switchTab('dashboard')">Dashboard</button> <button class="tab active" onclick="switchTab('dashboard')">Dashboard</button>
<button class="tab" onclick="switchTab('jobs')">Jobs</button> <button class="tab" onclick="switchTab('jobs')">Jobs</button>
<button class="tab" onclick="switchTab('stations')">Sender</button>
<button class="tab" onclick="switchTab('logs')">Logs</button> <button class="tab" onclick="switchTab('logs')">Logs</button>
<button class="tab" onclick="switchTab('settings')">Einstellungen</button> <button class="tab" onclick="switchTab('settings')">Einstellungen</button>
</div> </div>
@@ -373,6 +466,15 @@
<div id="jobsList"></div> <div id="jobsList"></div>
</div> </div>
<!-- Stations -->
<div class="panel" id="panel-stations">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<p class="section-title" style="margin:0">Senderbibliothek</p>
<button class="btn btn-primary btn-sm" onclick="openStationModal()">+ Neuer Sender</button>
</div>
<div id="stationsList"></div>
</div>
<!-- Logs --> <!-- Logs -->
<div class="panel" id="panel-logs"> <div class="panel" id="panel-logs">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
@@ -402,6 +504,12 @@
<div class="modal-overlay" id="quickRecordModal"> <div class="modal-overlay" id="quickRecordModal">
<div class="modal"> <div class="modal">
<div class="modal-title">Schnellaufnahme</div> <div class="modal-title">Schnellaufnahme</div>
<div class="form-group">
<label>Senderbibliothek</label>
<select id="qr-station" onchange="applyStationToQuickRecord()">
<option value="">URL manuell eingeben</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label>Stream-URL *</label> <label>Stream-URL *</label>
<input type="text" id="qr-url" placeholder="rtmp://... oder https://...m3u8"> <input type="text" id="qr-url" placeholder="rtmp://... oder https://...m3u8">
@@ -443,6 +551,12 @@
<div class="form-grid"> <div class="form-grid">
<div class="form-group full"> <div class="form-group full">
<label>Senderbibliothek</label>
<select id="job-station" onchange="applyStationToJob()">
<option value="">URL manuell eingeben</option>
</select>
</div>
<div class="form-group full" id="jobUrlGroup">
<label>Stream-URL *</label> <label>Stream-URL *</label>
<input type="text" id="job-url" placeholder="rtmp://... oder https://...m3u8"> <input type="text" id="job-url" placeholder="rtmp://... oder https://...m3u8">
</div> </div>
@@ -522,6 +636,23 @@
<input type="time" id="job-schedule-stop" oninput="updateDurationField()"> <input type="time" id="job-schedule-stop" oninput="updateDurationField()">
</div> </div>
</div> </div>
<div style="margin-top:8px; padding:8px 12px; background:var(--bg-input); border:1px solid var(--border); border-radius:6px">
<div style="font-size:0.8rem; color:var(--text-dim); margin-bottom:6px">Zeitpuffer</div>
<div style="display:flex; gap:16px; flex-wrap:wrap">
<div class="checkbox-group">
<input type="checkbox" id="job-pre-buffer-enabled" onchange="togglePreBuffer()">
<label for="job-pre-buffer-enabled" style="margin:0">Vorlaufzeit</label>
<input type="number" id="job-pre-buffer" min="1" max="120" style="width:60px; display:none" oninput="updateDurationField()">
<span id="job-pre-buffer-unit" style="font-size:0.8rem; color:var(--text-dim); display:none">Min.</span>
</div>
<div class="checkbox-group">
<input type="checkbox" id="job-post-buffer-enabled" onchange="togglePostBuffer()">
<label for="job-post-buffer-enabled" style="margin:0">Nachlaufzeit</label>
<input type="number" id="job-post-buffer" min="1" max="120" style="width:60px; display:none" oninput="updateDurationField()">
<span id="job-post-buffer-unit" style="font-size:0.8rem; color:var(--text-dim); display:none">Min.</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
@@ -541,28 +672,7 @@
<div id="plikFields" style="display:none; margin-top:8px; margin-left:24px"> <div id="plikFields" style="display:none; margin-top:8px; margin-left:24px">
<div class="checkbox-group"> <div class="checkbox-group">
<input type="checkbox" id="job-delete-after"> <input type="checkbox" id="job-delete-after">
<label for="job-delete-after" style="margin:0">Nach Upload löschen</label> <label for="job-delete-after" style="margin:0">File nach Upload lokal löschen</label>
</div>
</div>
</div>
<div class="job-section" style="margin-top:12px; border-top:1px solid var(--border); padding-top:12px">
<div class="checkbox-group">
<input type="checkbox" id="job-metadata-enabled" onchange="toggleMetadata()">
<label for="job-metadata-enabled" style="margin:0">Euer-Radio Metadata-Monitor</label>
</div>
<div id="metadataFields" style="display:none; margin-top:8px" class="form-grid">
<div class="form-group full">
<label>Show-Pattern (z.B. Sendungsname)</label>
<input type="text" id="job-metadata-pattern" placeholder="Donnerwolke">
</div>
<div class="form-group">
<label>Karenzzeit (Min.)</label>
<input type="number" id="job-metadata-grace" value="5" min="1">
</div>
<div class="form-group">
<label>Poll-Intervall (Sek.)</label>
<input type="number" id="job-metadata-poll" value="30" min="10">
</div> </div>
</div> </div>
</div> </div>
@@ -574,6 +684,44 @@
</div> </div>
</div> </div>
<!-- Station Modal -->
<div class="modal-overlay" id="stationModal">
<div class="modal">
<div class="modal-title" id="stationModalTitle">Neuer Sender</div>
<input type="hidden" id="station-edit-id">
<div class="form-grid">
<div class="form-group">
<label>Name *</label>
<input type="text" id="station-name" placeholder="Sendername">
</div>
<div class="form-group">
<label>Typ</label>
<select id="station-type">
<option value="auto">Automatisch</option>
<option value="rtmp">RTMP</option>
<option value="hls">HLS</option>
<option value="mp3">MP3</option>
<option value="aac">AAC</option>
<option value="ogg">OGG</option>
<option value="http">HTTP</option>
</select>
</div>
<div class="form-group full">
<label>Stream-URL *</label>
<input type="text" id="station-url" placeholder="rtmp://... oder https://...m3u8">
</div>
<div class="form-group full">
<label>Beschreibung (optional)</label>
<textarea id="station-description" rows="3" placeholder="z.B. Hauptstream, Wochenendprogramm..."></textarea>
</div>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('stationModal')">Abbrechen</button>
<button class="btn btn-primary" onclick="saveStation()">Speichern</button>
</div>
</div>
</div>
<!-- Extend Modal --> <!-- Extend Modal -->
<div class="modal-overlay" id="extendModal"> <div class="modal-overlay" id="extendModal">
<div class="modal" style="max-width:400px"> <div class="modal" style="max-width:400px">
@@ -600,8 +748,23 @@
</div> </div>
</div> </div>
<!-- Recording Detail Modal -->
<div class="modal-overlay" id="recDetailModal">
<div class="modal" style="max-width:560px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<div class="modal-title" id="recDetailTitle" style="margin:0"></div>
<div id="recDetailBadges"></div>
</div>
<div class="rec-detail-grid" id="recDetailBody"></div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('recDetailModal')">Schließen</button>
</div>
</div>
</div>
<script> <script>
const API = ''; const API = '';
let stationsCache = [];
async function api(path, opts = {}) { async function api(path, opts = {}) {
const res = await fetch(API + path, { const res = await fetch(API + path, {
@@ -615,17 +778,31 @@ async function api(path, opts = {}) {
return res.json(); return res.json();
} }
// Burger menu
function toggleBurger() {
document.getElementById('navTabs').classList.toggle('open');
}
// Tabs // Tabs
function switchTab(name) { function switchTab(name) {
document.getElementById('navTabs').classList.remove('open');
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.querySelector(`#panel-${name}`).classList.add('active'); document.querySelector(`#panel-${name}`).classList.add('active');
const tabLabel = {
dashboard: 'dash',
jobs: 'jobs',
stations: 'sender',
logs: 'logs',
settings: 'einst',
}[name] || name;
document.querySelectorAll('.tab').forEach(t => { document.querySelectorAll('.tab').forEach(t => {
if (t.textContent.toLowerCase().includes(name === 'dashboard' ? 'dash' : name === 'settings' ? 'einst' : name)) if (t.textContent.toLowerCase().includes(tabLabel))
t.classList.add('active'); t.classList.add('active');
}); });
if (name === 'logs') loadLogs(); if (name === 'logs') loadLogs();
if (name === 'jobs') loadJobs(); if (name === 'jobs') loadJobs();
if (name === 'stations') loadStations();
if (name === 'settings') loadSettings(); if (name === 'settings') loadSettings();
} }
@@ -635,10 +812,6 @@ async function loadDashboard() {
const active = await api('/api/recordings?active_only=true'); const active = await api('/api/recordings?active_only=true');
const recent = await api('/api/recordings?limit=20'); const recent = await api('/api/recordings?limit=20');
const dot = document.getElementById('recDot');
dot.style.background = active.length > 0 ? 'var(--recording)' : 'var(--text-dim)';
dot.style.animation = active.length > 0 ? 'pulse 2s ease-in-out infinite' : 'none';
renderActive(active); renderActive(active);
renderRecent(recent.filter(r => r.status !== 'running' && r.status !== 'starting')); renderRecent(recent.filter(r => r.status !== 'running' && r.status !== 'starting'));
} catch (e) { } catch (e) {
@@ -660,7 +833,7 @@ function renderActive(recs) {
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="card-title"> <div class="card-title">
<span style="color:var(--recording)">&#9679;</span> <span class="rec-dot"></span>
${esc(r.job_name)} ${esc(r.job_name)}
<span class="badge badge-${r.status}">${r.status}</span> <span class="badge badge-${r.status}">${r.status}</span>
${r.is_scheduled ? '<span class="badge badge-scheduled">geplant</span>' : ''} ${r.is_scheduled ? '<span class="badge badge-scheduled">geplant</span>' : ''}
@@ -689,21 +862,220 @@ function renderRecent(recs) {
el.innerHTML = '<div class="empty" style="padding:24px"><span style="color:var(--text-dim)">Noch keine abgeschlossenen Aufnahmen</span></div>'; el.innerHTML = '<div class="empty" style="padding:24px"><span style="color:var(--text-dim)">Noch keine abgeschlossenen Aufnahmen</span></div>';
return; return;
} }
el.innerHTML = recs.slice(0, 10).map(r => ` el.innerHTML = recs.slice(0, 10).map(r => {
<div class="card" style="padding:12px"> const started = r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : '';
return `
<div class="card card-recent" style="padding:12px" onclick="openRecDetail(${r.id})">
<div style="display:flex;align-items:center;justify-content:space-between"> <div style="display:flex;align-items:center;justify-content:space-between">
<div> <div>
<span style="font-weight:500">${esc(r.job_name)}</span> <span style="font-weight:500">${esc(r.job_name)}</span>
<span class="badge badge-${r.status}" style="margin-left:8px">${r.status}</span> <span class="badge badge-${r.status}" style="margin-left:8px">${r.status}</span>
${r.plik_url ? '<span class="badge badge-completed" style="margin-left:4px">&#128228; Plik</span>' : ''}
</div> </div>
<div class="meta"> <div class="recent-meta">
<span class="meta-item">&#128339; ${r.elapsed_display}</span> <span class="rm-dur">&#128339; ${r.elapsed_display}</span>
<span class="meta-item">&#128190; ${r.total_size_display}</span> <span class="rm-size">&#128190; ${r.total_size_display}</span>
<span class="meta-item">${r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : ''}</span> <span class="rm-date">${started}</span>
</div> </div>
</div> </div>
</div> </div>`;
`).join(''); }).join('');
}
async function openRecDetail(recId) {
try {
const r = await api('/api/recordings/' + recId);
const started = r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : '—';
const stopped = r.stopped_at ? new Date(r.stopped_at).toLocaleString('de-DE') : '—';
const fileName = r.output_file ? r.output_file.split('/').pop() : null;
document.getElementById('recDetailTitle').textContent = r.job_name;
document.getElementById('recDetailBadges').innerHTML =
`<span class="badge badge-${r.status}">${r.status}</span>` +
(r.plik_url ? ' <span class="badge badge-completed">&#128228; Plik</span>' : '');
let html = '';
if (r.plik_url) {
html += `<div class="rec-detail-item full">
<span class="rec-detail-label">Plik-Download</span>
<span class="rec-detail-value"><a class="rec-plik-link" href="${esc(r.plik_url)}" target="_blank" rel="noopener">&#128279; Im Browser öffnen</a></span>
</div>
<hr class="rec-detail-divider">`;
}
html += `
<div class="rec-detail-item">
<span class="rec-detail-label">Gestartet</span>
<span class="rec-detail-value">${started}</span>
</div>
<div class="rec-detail-item">
<span class="rec-detail-label">Beendet</span>
<span class="rec-detail-value">${stopped}</span>
</div>
<div class="rec-detail-item">
<span class="rec-detail-label">Dauer</span>
<span class="rec-detail-value">${r.elapsed_display}</span>
</div>
<div class="rec-detail-item">
<span class="rec-detail-label">Dateigröße</span>
<span class="rec-detail-value">${r.total_size_display}</span>
</div>`;
if (r.error_message) {
html += `<div class="rec-detail-item full">
<span class="rec-detail-label">Fehler</span>
<span class="rec-detail-value" style="color:var(--error)">${esc(r.error_message)}</span>
</div>`;
}
html += '<hr class="rec-detail-divider">';
if (fileName) {
html += `<div class="rec-detail-item full">
<span class="rec-detail-label">Datei</span>
<span class="rec-detail-value">${esc(fileName)}</span>
</div>`;
}
if (r.output_dir) {
html += `<div class="rec-detail-item full">
<span class="rec-detail-label">Verzeichnis</span>
<span class="rec-detail-value">${esc(r.output_dir)}</span>
</div>`;
}
html += `<div class="rec-detail-item full">
<span class="rec-detail-label">Stream-URL</span>
<span class="rec-detail-value">${esc(r.stream_url)}</span>
</div>`;
document.getElementById('recDetailBody').innerHTML = html;
openModal('recDetailModal');
} catch (e) {
console.error(e);
}
}
// Stations
async function loadStationOptions() {
try {
stationsCache = await api('/api/stations');
populateStationSelects();
} catch (e) {
console.error(e);
}
}
function populateStationSelects() {
['qr-station', 'job-station'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
const selected = el.value;
el.innerHTML = '<option value="">URL manuell eingeben</option>' + stationsCache.map(s =>
`<option value="${s.id}">${esc(s.name)}${s.description ? ' - ' + esc(s.description) : ''}</option>`
).join('');
if (stationsCache.some(s => String(s.id) === selected)) el.value = selected;
});
}
async function loadStations() {
try {
await loadStationOptions();
const el = document.getElementById('stationsList');
if (!stationsCache.length) {
el.innerHTML = '<div class="empty"><div class="empty-icon">&#128251;</div>Keine Sender gespeichert<br><button class="btn btn-primary" style="margin-top:12px" onclick="openStationModal()">Ersten Sender anlegen</button></div>';
return;
}
el.innerHTML = stationsCache.map(s => `
<div class="card">
<div class="card-header">
<div class="card-title">
${esc(s.name)}
<span class="badge badge-scheduled">${esc(s.stream_type || 'auto')}</span>
</div>
<div class="actions">
<button class="btn btn-sm" onclick="openStationModal(${s.id})">Bearbeiten</button>
<button class="btn btn-sm btn-danger" onclick="deleteStation(${s.id})">Löschen</button>
</div>
</div>
${s.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(s.description)}</span></div>` : ''}
<div class="meta">
<span class="meta-item">${esc(s.stream_url)}</span>
</div>
</div>
`).join('');
} catch (e) {
console.error(e);
}
}
function openStationModal(id = null) {
const station = id ? stationsCache.find(s => s.id === id) : null;
document.getElementById('station-edit-id').value = station ? station.id : '';
document.getElementById('stationModalTitle').textContent = station ? 'Sender bearbeiten' : 'Neuer Sender';
document.getElementById('station-name').value = station ? station.name : '';
document.getElementById('station-url').value = station ? station.stream_url : '';
document.getElementById('station-type').value = station ? station.stream_type || 'auto' : 'auto';
document.getElementById('station-description').value = station ? station.description || '' : '';
openModal('stationModal');
}
async function saveStation() {
const editId = document.getElementById('station-edit-id').value;
const data = {
name: document.getElementById('station-name').value,
stream_url: document.getElementById('station-url').value,
stream_type: document.getElementById('station-type').value,
description: document.getElementById('station-description').value,
};
if (!data.name || !data.stream_url) {
alert('Name und URL sind Pflichtfelder');
return;
}
try {
if (editId) {
await api(`/api/stations/${editId}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
await api('/api/stations', { method: 'POST', body: JSON.stringify(data) });
}
closeModal('stationModal');
loadStations();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
async function deleteStation(id) {
if (!confirm('Sender wirklich löschen?')) return;
await api(`/api/stations/${id}`, { method: 'DELETE' });
loadStations();
}
function applyStationToQuickRecord() {
const id = document.getElementById('qr-station').value;
const station = stationsCache.find(s => String(s.id) === id);
if (!station) return;
document.getElementById('qr-url').value = station.stream_url;
document.getElementById('qr-type').value = station.stream_type || 'auto';
if (!document.getElementById('qr-name').value) {
document.getElementById('qr-name').value = station.name;
}
}
function applyStationToJob() {
const id = document.getElementById('job-station').value;
const station = stationsCache.find(s => String(s.id) === id);
document.getElementById('jobUrlGroup').style.display = station ? 'none' : '';
if (!station) return;
document.getElementById('job-url').value = station.stream_url;
document.getElementById('job-type').value = station.stream_type || 'auto';
if (!document.getElementById('job-name').value) {
document.getElementById('job-name').value = station.name;
}
if (!document.getElementById('job-description').value && station.description) {
document.getElementById('job-description').value = station.description;
}
} }
// Jobs // Jobs
@@ -732,13 +1104,12 @@ async function loadJobs() {
${j.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(j.description)}</span></div>` : ''} ${j.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(j.description)}</span></div>` : ''}
<div class="meta"> <div class="meta">
<span class="meta-item">${esc(j.stream_type || 'auto')}</span> <span class="meta-item">${esc(j.stream_type || 'auto')}</span>
<span class="meta-item">${esc(j.stream_url)}</span> <span class="meta-item">${j.station_name ? '&#128251; ' + esc(j.station_name) : esc(j.stream_url)}</span>
${j.max_duration ? `<span class="meta-item">&#128339; ${Math.round(j.max_duration/60)} Min</span>` : ''} ${j.max_duration ? `<span class="meta-item">&#128339; ${Math.round(j.max_duration/60)} Min</span>` : ''}
${j.segment_duration ? `<span class="meta-item">&#128196; alle ${Math.round(j.segment_duration/60)} Min</span>` : ''} ${j.segment_duration ? `<span class="meta-item">&#128196; alle ${Math.round(j.segment_duration/60)} Min</span>` : ''}
${j.schedule_enabled ? `<span class="meta-item">&#128197; ${j.schedule_once ? j.schedule_date : j.schedule_days} ${j.schedule_start}${j.schedule_stop ? ' - '+j.schedule_stop : ''}</span>` : ''} ${j.schedule_enabled ? `<span class="meta-item">&#128197; ${j.schedule_once ? j.schedule_date : j.schedule_days} ${j.schedule_start}${j.schedule_stop ? ' - '+j.schedule_stop : ''}${j.pre_buffer ? ' (&#8722;'+j.pre_buffer+'min)' : ''}${j.post_buffer ? ' (+'+j.post_buffer+'min)' : ''}</span>` : ''}
${j.ntfy_enabled ? `<span class="meta-item">&#128276; NTFY</span>` : ''} ${j.ntfy_enabled ? `<span class="meta-item">&#128276; NTFY</span>` : ''}
${j.plik_enabled ? `<span class="meta-item">&#128228; Plik${j.delete_after_upload ? ' (löschen)' : ''}</span>` : ''} ${j.plik_enabled ? `<span class="meta-item">&#128228; Plik${j.delete_after_upload ? ' (danach löschen)' : ''}</span>` : ''}
${j.metadata_monitor_enabled ? `<span class="meta-item">&#128225; ${esc(j.metadata_pattern)}</span>` : ''}
</div> </div>
</div> </div>
`).join(''); `).join('');
@@ -748,8 +1119,11 @@ async function loadJobs() {
} }
function openJobModal(job = null) { function openJobModal(job = null) {
loadStationOptions();
document.getElementById('job-edit-id').value = job ? job.id : ''; document.getElementById('job-edit-id').value = job ? job.id : '';
document.getElementById('jobModalTitle').textContent = job ? 'Job bearbeiten' : 'Neuer Job'; document.getElementById('jobModalTitle').textContent = job ? 'Job bearbeiten' : 'Neuer Job';
document.getElementById('job-station').value = job && job.station_id ? job.station_id : '';
document.getElementById('jobUrlGroup').style.display = (job && job.station_id) ? 'none' : '';
document.getElementById('job-url').value = job ? job.stream_url : ''; document.getElementById('job-url').value = job ? job.stream_url : '';
document.getElementById('job-name').value = job ? job.name : ''; document.getElementById('job-name').value = job ? job.name : '';
document.getElementById('job-type').value = job ? job.stream_type : 'auto'; document.getElementById('job-type').value = job ? job.stream_type : 'auto';
@@ -768,14 +1142,15 @@ function openJobModal(job = null) {
document.getElementById('job-ntfy-enabled').checked = job ? job.ntfy_enabled : false; document.getElementById('job-ntfy-enabled').checked = job ? job.ntfy_enabled : false;
document.getElementById('job-plik-enabled').checked = job ? job.plik_enabled : false; document.getElementById('job-plik-enabled').checked = job ? job.plik_enabled : false;
document.getElementById('job-delete-after').checked = job ? job.delete_after_upload : false; document.getElementById('job-delete-after').checked = job ? job.delete_after_upload : false;
document.getElementById('job-metadata-enabled').checked = job ? job.metadata_monitor_enabled : false; document.getElementById('job-pre-buffer-enabled').checked = job ? (job.pre_buffer || 0) > 0 : false;
document.getElementById('job-metadata-pattern').value = job ? job.metadata_pattern || '' : ''; document.getElementById('job-pre-buffer').value = job && job.pre_buffer ? job.pre_buffer : '';
document.getElementById('job-metadata-grace').value = job ? Math.round((job.metadata_grace_period || 300) / 60) : 5; document.getElementById('job-post-buffer-enabled').checked = job ? (job.post_buffer || 0) > 0 : false;
document.getElementById('job-metadata-poll').value = job ? job.metadata_poll_interval || 30 : 30; document.getElementById('job-post-buffer').value = job && job.post_buffer ? job.post_buffer : '';
toggleSchedule(); toggleSchedule();
toggleScheduleOnce(); toggleScheduleOnce();
togglePreBuffer();
togglePostBuffer();
togglePlik(); togglePlik();
toggleMetadata();
openModal('jobModal'); openModal('jobModal');
} }
@@ -803,14 +1178,13 @@ async function saveJob() {
schedule_start: document.getElementById('job-schedule-start').value, schedule_start: document.getElementById('job-schedule-start').value,
schedule_stop: document.getElementById('job-schedule-stop').value, schedule_stop: document.getElementById('job-schedule-stop').value,
schedule_end_date: document.getElementById('job-schedule-end-date').value, schedule_end_date: document.getElementById('job-schedule-end-date').value,
pre_buffer: document.getElementById('job-pre-buffer-enabled').checked ? parseInt(document.getElementById('job-pre-buffer').value || '0') : 0,
post_buffer: document.getElementById('job-post-buffer-enabled').checked ? parseInt(document.getElementById('job-post-buffer').value || '0') : 0,
description: document.getElementById('job-description').value, description: document.getElementById('job-description').value,
ntfy_enabled: document.getElementById('job-ntfy-enabled').checked, ntfy_enabled: document.getElementById('job-ntfy-enabled').checked,
plik_enabled: document.getElementById('job-plik-enabled').checked, plik_enabled: document.getElementById('job-plik-enabled').checked,
delete_after_upload: document.getElementById('job-delete-after').checked, delete_after_upload: document.getElementById('job-delete-after').checked,
metadata_monitor_enabled: document.getElementById('job-metadata-enabled').checked, station_id: document.getElementById('job-station').value ? parseInt(document.getElementById('job-station').value) : null,
metadata_pattern: document.getElementById('job-metadata-pattern').value,
metadata_grace_period: parseInt(document.getElementById('job-metadata-grace').value || '5') * 60,
metadata_poll_interval: parseInt(document.getElementById('job-metadata-poll').value || '30'),
}; };
if (!data.name || !data.stream_url) { if (!data.name || !data.stream_url) {
@@ -866,11 +1240,14 @@ function updateDurationField() {
document.getElementById('durationAutoGroup').style.display = hasScheduleStop ? '' : 'none'; document.getElementById('durationAutoGroup').style.display = hasScheduleStop ? '' : 'none';
if (hasScheduleStop) { if (hasScheduleStop) {
const preBuf = document.getElementById('job-pre-buffer-enabled').checked ? parseInt(document.getElementById('job-pre-buffer').value || '0') : 0;
const postBuf = document.getElementById('job-post-buffer-enabled').checked ? parseInt(document.getElementById('job-post-buffer').value || '0') : 0;
const bufTotal = preBuf + postBuf;
let text; let text;
if (once && startDate && endDate && endDate !== startDate) { if (once && startDate && endDate && endDate !== startDate) {
const startDt = new Date(`${startDate}T${start}`); const startDt = new Date(`${startDate}T${start}`);
const endDt = new Date(`${endDate}T${stop}`); const endDt = new Date(`${endDate}T${stop}`);
let diffMin = Math.round((endDt - startDt) / 60000); let diffMin = Math.round((endDt - startDt) / 60000) + bufTotal;
if (diffMin <= 0) { text = 'Enddatum muss nach Startdatum liegen'; } if (diffMin <= 0) { text = 'Enddatum muss nach Startdatum liegen'; }
else { else {
const d = Math.floor(diffMin / 1440); const d = Math.floor(diffMin / 1440);
@@ -881,15 +1258,18 @@ function updateDurationField() {
if (h > 0) parts.push(`${h} Std.`); if (h > 0) parts.push(`${h} Std.`);
if (m > 0) parts.push(`${m} Min.`); if (m > 0) parts.push(`${m} Min.`);
text = `${parts.join(' ')} (mehrtägig, aus Zeitplan)`; text = `${parts.join(' ')} (mehrtägig, aus Zeitplan)`;
if (bufTotal > 0) text += ` inkl. ${bufTotal} Min. Puffer`;
} }
} else { } else {
const [sh, sm] = start.split(':').map(Number); const [sh, sm] = start.split(':').map(Number);
const [eh, em] = stop.split(':').map(Number); const [eh, em] = stop.split(':').map(Number);
let diff = (eh * 60 + em) - (sh * 60 + sm); let diff = (eh * 60 + em) - (sh * 60 + sm);
if (diff <= 0) diff += 1440; if (diff <= 0) diff += 1440;
diff += bufTotal;
const h = Math.floor(diff / 60); const h = Math.floor(diff / 60);
const m = diff % 60; const m = diff % 60;
text = (h > 0 ? `${h} Std. ${m > 0 ? m + ' Min.' : ''}` : `${m} Min.`) + ' (automatisch aus Zeitplan)'; text = (h > 0 ? `${h} Std. ${m > 0 ? m + ' Min.' : ''}` : `${m} Min.`) + ' (aus Zeitplan)';
if (bufTotal > 0) text += ` inkl. ${bufTotal} Min. Puffer`;
} }
document.getElementById('durationAutoText').textContent = text; document.getElementById('durationAutoText').textContent = text;
} }
@@ -903,18 +1283,35 @@ function toggleScheduleOnce() {
updateDurationField(); updateDurationField();
} }
function togglePreBuffer() {
const on = document.getElementById('job-pre-buffer-enabled').checked;
document.getElementById('job-pre-buffer').style.display = on ? '' : 'none';
document.getElementById('job-pre-buffer-unit').style.display = on ? '' : 'none';
if (on && !document.getElementById('job-pre-buffer').value) {
document.getElementById('job-pre-buffer').value = window._defaultPreBuffer || 5;
}
updateDurationField();
}
function togglePostBuffer() {
const on = document.getElementById('job-post-buffer-enabled').checked;
document.getElementById('job-post-buffer').style.display = on ? '' : 'none';
document.getElementById('job-post-buffer-unit').style.display = on ? '' : 'none';
if (on && !document.getElementById('job-post-buffer').value) {
document.getElementById('job-post-buffer').value = window._defaultPostBuffer || 5;
}
updateDurationField();
}
function togglePlik() { function togglePlik() {
document.getElementById('plikFields').style.display = document.getElementById('plikFields').style.display =
document.getElementById('job-plik-enabled').checked ? 'block' : 'none'; document.getElementById('job-plik-enabled').checked ? 'block' : 'none';
} }
function toggleMetadata() {
document.getElementById('metadataFields').style.display =
document.getElementById('job-metadata-enabled').checked ? 'grid' : 'none';
}
// Quick Record // Quick Record
function openQuickRecord() { function openQuickRecord() {
loadStationOptions();
document.getElementById('qr-station').value = '';
document.getElementById('qr-url').value = ''; document.getElementById('qr-url').value = '';
document.getElementById('qr-name').value = ''; document.getElementById('qr-name').value = '';
document.getElementById('qr-type').value = 'auto'; document.getElementById('qr-type').value = 'auto';
@@ -1033,6 +1430,19 @@ async function loadSettings() {
</div> </div>
</div> </div>
</div> </div>
<div class="settings-section">
<h3>Zeitplan-Puffer (Standardwerte)</h3>
<div class="form-grid">
<div class="form-group">
<label>Standard-Vorlaufzeit (Min.)</label>
<input type="number" id="cfg-pre-buffer" min="1" max="120" value="${cfg.schedule?.default_pre_buffer ?? 5}">
</div>
<div class="form-group">
<label>Standard-Nachlaufzeit (Min.)</label>
<input type="number" id="cfg-post-buffer" min="1" max="120" value="${cfg.schedule?.default_post_buffer ?? 5}">
</div>
</div>
</div>
<div class="settings-section"> <div class="settings-section">
<h3>NTFY-Benachrichtigungen</h3> <h3>NTFY-Benachrichtigungen</h3>
<div class="form-grid"> <div class="form-grid">
@@ -1081,12 +1491,19 @@ async function saveSettings() {
}; };
if (passVal && passVal !== '***') authData.password = passVal; if (passVal && passVal !== '***') authData.password = passVal;
window._defaultPreBuffer = parseInt(document.getElementById('cfg-pre-buffer').value) || 5;
window._defaultPostBuffer = parseInt(document.getElementById('cfg-post-buffer').value) || 5;
const data = { const data = {
recording: { recording: {
download_path: document.getElementById('cfg-download-path').value, download_path: document.getElementById('cfg-download-path').value,
max_retries: parseInt(document.getElementById('cfg-max-retries').value) || 5, max_retries: parseInt(document.getElementById('cfg-max-retries').value) || 5,
retry_delay: parseInt(document.getElementById('cfg-retry-delay').value) || 5, retry_delay: parseInt(document.getElementById('cfg-retry-delay').value) || 5,
}, },
schedule: {
default_pre_buffer: parseInt(document.getElementById('cfg-pre-buffer').value) || 5,
default_post_buffer: parseInt(document.getElementById('cfg-post-buffer').value) || 5,
},
auth: authData, auth: authData,
ntfy: { ntfy: {
url: document.getElementById('cfg-ntfy-url').value, url: document.getElementById('cfg-ntfy-url').value,
@@ -1120,12 +1537,36 @@ document.querySelectorAll('.modal-overlay').forEach(overlay => {
}); });
}); });
// Close burger menu on outside click
document.addEventListener('click', e => {
const tabs = document.getElementById('navTabs');
const burger = document.getElementById('burgerBtn');
if (tabs.classList.contains('open') && !tabs.contains(e.target) && e.target !== burger) {
tabs.classList.remove('open');
}
});
// Load schedule defaults from config
async function loadDefaults() {
try {
const cfg = await api('/api/config');
window._defaultPreBuffer = cfg.schedule?.default_pre_buffer ?? 5;
window._defaultPostBuffer = cfg.schedule?.default_post_buffer ?? 5;
} catch (e) { /* defaults stay at 5 */ }
}
// Auto-refresh // Auto-refresh
loadStationOptions();
loadDefaults();
loadDashboard(); loadDashboard();
setInterval(loadDashboard, 5000); setInterval(loadDashboard, 5000);
api('/api/health').then(h => {
document.getElementById('footerVersion').textContent = 'Stream-Recorder v' + h.version;
}).catch(() => {});
</script> </script>
<footer style="text-align:center;padding:32px 16px 16px;color:var(--text-dim);font-size:0.75rem"> <footer class="site-footer">
stream-recorder &copy; <a href="https://www.patrick-asmus.de" target="_blank" rel="noopener" style="color:var(--text-dim)">Patrick Asmus</a> &middot; <a href="https://git.techniverse.net/scriptos/stream-recorder" target="_blank" rel="noopener" style="color:var(--accent)">Source</a> <span id="footerVersion">Stream-Recorder</span> &copy; <a href="https://www.patrick-asmus.de" target="_blank" rel="noopener" style="color:var(--text-dim)">Patrick Asmus</a> &middot; <a href="https://git.techniverse.net/scriptos/stream-recorder" target="_blank" rel="noopener" style="color:var(--accent)">Source</a>
</footer> </footer>
</body> </body>
</html> </html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

+7 -7
View File
@@ -4,16 +4,16 @@ services:
container_name: stream-recorder container_name: stream-recorder
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${APP_PORT_BIND:-8484}:8484" - "8484:8484"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
# Optional: Recordings auf separatem Pfad (dann download_path in Einstellungen anpassen) # Optional: Recordings auf separatem Pfad (dann download_path in Einstellungen anpassen)
# - /mnt/storage/recordings:/recordings # - /mnt/storage/recordings:/recordings
networks: networks:
stream-recorder_net: stream-recorder_net:
ipv4_address: ${APP_IPV4_ADDRESS:-172.16.84.10} ipv4_address: 172.16.84.10
environment: environment:
TZ: ${TZ:-Europe/Berlin} TZ: Europe/Berlin
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8484/api/health"] test: ["CMD", "curl", "-f", "http://localhost:8484/api/health"]
interval: 30s interval: 30s
@@ -23,10 +23,10 @@ services:
networks: networks:
stream-recorder_net: stream-recorder_net:
name: ${DOCKER_NETWORK_NAME:-stream-recorder.dockernetwork.local} name: stream-recorder.dockernetwork.local
driver: bridge driver: bridge
ipam: ipam:
config: config:
- subnet: ${DOCKER_NETWORK_SUBNET:-172.16.84.0/24} - subnet: 172.16.84.0/24
gateway: ${DOCKER_NETWORK_GATEWAY:-172.16.84.1} gateway: 172.16.84.1
ip_range: ${DOCKER_NETWORK_IP_RANGE:-172.16.84.128/25} ip_range: 172.16.84.128/25
+10
View File
@@ -23,6 +23,16 @@ Alle Funktionen des Web-UI sind auch per REST-API verfügbar. Basis-URL: `http:/
| `DELETE` | `/api/jobs/:id` | Job löschen | | `DELETE` | `/api/jobs/:id` | Job löschen |
| `POST` | `/api/jobs/:id/record` | Job-Aufnahme starten | | `POST` | `/api/jobs/:id/record` | Job-Aufnahme starten |
### Senderbibliothek
| Methode | Pfad | Beschreibung |
|---------|------|-------------|
| `GET` | `/api/stations` | Alle gespeicherten Sender auflisten |
| `POST` | `/api/stations` | Neuen Sender erstellen |
| `GET` | `/api/stations/:id` | Sender-Details abrufen |
| `PUT` | `/api/stations/:id` | Sender aktualisieren |
| `DELETE` | `/api/stations/:id` | Sender löschen |
### Schnellaufnahme ### Schnellaufnahme
| Methode | Pfad | Beschreibung | | Methode | Pfad | Beschreibung |
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+15
View File
@@ -2,14 +2,29 @@
## Aktive Aufnahmen ## Aktive Aufnahmen
![Dashboard Übersicht](assets/img1_dashboard.png)
Das Dashboard zeigt alle laufenden Aufnahmen mit: Das Dashboard zeigt alle laufenden Aufnahmen mit:
- Laufzeit - Laufzeit
- Dateigröße - Dateigröße
- Verbleibende Zeit (bei Zeitlimit) - Verbleibende Zeit (bei Zeitlimit)
- Segment-Anzahl (bei Segment-Splitting) - Segment-Anzahl (bei Segment-Splitting)
## Letzte Aufnahmen
Unterhalb der aktiven Aufnahmen zeigt das Dashboard die letzten abgeschlossenen Aufnahmen. Per Klick auf eine Aufnahme wird eine Detailansicht ausgeklappt mit:
- **Status** — abgeschlossen, gestoppt oder Fehler (inkl. Fehlermeldung)
- **Start- und Endzeit**
- **Dauer und Dateigröße**
- **Plik-URL** — Direkter Link zum Download, falls die Aufnahme zu Plik hochgeladen wurde
- **Dateipfad** — Lokaler Pfad zur Aufnahmedatei (sofern nicht nach Upload gelöscht)
- **Stream-URL**
## Aufnahme verlängern ## Aufnahme verlängern
![Aufnahme verlängern](assets/img5_rec.png)
Laufende Aufnahmen können verlängert werden: Laufende Aufnahmen können verlängert werden:
- **+15 / +30 / +60 / +120 Minuten** — Schnellauswahl - **+15 / +30 / +60 / +120 Minuten** — Schnellauswahl
- **Eigene Dauer** — Beliebige Minutenzahl eingeben - **Eigene Dauer** — Beliebige Minutenzahl eingeben
+36 -2
View File
@@ -1,5 +1,7 @@
# Jobs # Jobs
![Jobs](assets/img2_jobs-overview.png)
Jobs sind vorkonfigurierte Aufnahme-Vorlagen. Jeder Job definiert einen Stream, optional einen Zeitplan und Plugin-Einstellungen. Jobs sind vorkonfigurierte Aufnahme-Vorlagen. Jeder Job definiert einen Stream, optional einen Zeitplan und Plugin-Einstellungen.
## Job erstellen ## Job erstellen
@@ -17,6 +19,8 @@ Im Web-UI unter **Jobs → + Neuer Job**.
| Feld | Beschreibung | | Feld | Beschreibung |
|------|-------------| |------|-------------|
| **Senderbibliothek** | Sender aus der Bibliothek auswählen — URL und Typ werden übernommen |
| **Beschreibung** | Freitext-Notiz zum Job (wird auch in NTFY-Nachrichten mitgeschickt) |
| **Typ** | Automatisch erkannt aus URL. Manuell wählbar: RTMP, HLS, MP3, AAC, OGG, HTTP | | **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) | | **Ausgabeformat** | Dateiendung. Leer = automatisch (mp4 für Video, mp3 für Audio) |
| **Max. Dauer** | Zeitlimit in Minuten. Entfällt bei Zeitplan mit Endzeit | | **Max. Dauer** | Zeitlimit in Minuten. Entfällt bei Zeitplan mit Endzeit |
@@ -30,7 +34,8 @@ Aktiviert automatische Aufnahmen zu festgelegten Zeiten.
| Feld | Beschreibung | | Feld | Beschreibung |
|------|-------------| |------|-------------|
| **Einmalig** | Nur einmal am angegebenen Datum ausführen | | **Einmalig** | Nur einmal am angegebenen Datum ausführen |
| **Datum** | Datum für einmalige Aufnahme | | **Startdatum** | Datum für einmalige Aufnahme |
| **Enddatum** | Enddatum für mehrtägige Aufnahmen (leer = gleicher Tag) |
| **Tage** | Wochentage: `*` (täglich), `Mo-Fr`, `Sa,So`, `Mo,Mi,Fr` | | **Tage** | Wochentage: `*` (täglich), `Mo-Fr`, `Sa,So`, `Mo,Mi,Fr` |
| **Startzeit** | Aufnahme-Beginn (HH:MM) | | **Startzeit** | Aufnahme-Beginn (HH:MM) |
| **Endzeit** | Aufnahme-Ende (HH:MM) — Dauer wird automatisch berechnet | | **Endzeit** | Aufnahme-Ende (HH:MM) — Dauer wird automatisch berechnet |
@@ -49,10 +54,39 @@ Wenn Start- und Endzeit gesetzt sind, wird die Aufnahmedauer automatisch berechn
Unterstützt: `Mo/Mon/Montag`, `Di/Tue/Dienstag`, `Mi/Wed/Mittwoch`, `Do/Thu/Donnerstag`, `Fr/Fri/Freitag`, `Sa/Sat/Samstag`, `So/Sun/Sonntag` Unterstützt: `Mo/Mon/Montag`, `Di/Tue/Dienstag`, `Mi/Wed/Mittwoch`, `Do/Thu/Donnerstag`, `Fr/Fri/Freitag`, `Sa/Sat/Samstag`, `So/Sun/Sonntag`
### Zeitpuffer
![Zeitpuffer](assets/img4_buffer.png)
Pro Job können Vor- und Nachlaufzeit konfiguriert werden, um die Aufnahme um den geplanten Zeitraum herum zu erweitern:
| Feld | Beschreibung |
|------|-------------|
| **Vorlaufzeit** | Aufnahme startet X Minuten vor der geplanten Startzeit |
| **Nachlaufzeit** | Aufnahme läuft X Minuten über die geplante Endzeit hinaus |
Die Standardwerte können in den Einstellungen unter **Zeitplan-Puffer** festgelegt werden.
### Mehrtägige Aufnahmen
Bei einmaligen Aufnahmen kann ein **Enddatum** angegeben werden, das nach dem Startdatum liegt. Die Aufnahmedauer wird dann automatisch über die Tagesgrenzen hinweg berechnet.
## Plugins pro Job
Jeder Job kann individuell Plugin-Funktionen aktivieren:
| Option | Beschreibung |
|--------|-------------|
| **Benachrichtigungen (NTFY)** | Push-Nachrichten bei Start, Stop und Fehlern für diesen Job |
| **Plik-Upload** | Automatischer Upload nach Aufnahme-Ende |
| **File nach Upload lokal löschen** | Lokale Datei nach erfolgreichem Plik-Upload entfernen |
Die globale NTFY- und Plik-Konfiguration (URL, Token, etc.) erfolgt in den [Einstellungen](konfiguration.md).
## Manuelle Aufnahme ## Manuelle Aufnahme
Jobs können auch manuell über den **Aufnehmen**-Button gestartet werden, unabhängig vom Zeitplan. Jobs können auch manuell über den **Aufnehmen**-Button gestartet werden, unabhängig vom Zeitplan.
## Schnellaufnahme ## Schnellaufnahme
Für einmalige Aufnahmen ohne Job: **Dashboard → + Schnellaufnahme**. URL eingeben, optional Name und Dauer angeben, sofort starten. Für einmalige Aufnahmen ohne Job: **Dashboard → + Schnellaufnahme**. URL eingeben (oder aus der Senderbibliothek wählen), optional Name und Dauer angeben, sofort starten.
+37 -2
View File
@@ -2,9 +2,11 @@
## config.yml ## config.yml
![Einstellungen](assets/img4_settings.png)
Die Konfiguration liegt in `data/config.yml` und kann über das Web-UI unter **Einstellungen** bearbeitet werden. Die Konfiguration liegt in `data/config.yml` und kann über das Web-UI unter **Einstellungen** bearbeitet werden.
Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine Vorlage mit Kommentaren findet sich in `config/config.yml.dist`. Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine Vorlage mit Kommentaren findet sich in `bin/config.yml.dist`.
## Abschnitte ## Abschnitte
@@ -13,9 +15,16 @@ Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine V
| Parameter | Standard | Beschreibung | | Parameter | Standard | Beschreibung |
|-----------|----------|-------------| |-----------|----------|-------------|
| `recording.download_path` | `/app/data/recordings` | Speicherort für Aufnahmen | | `recording.download_path` | `/app/data/recordings` | Speicherort für Aufnahmen |
| `recording.max_retries` | `5` | Max. Reconnect-Versuche bei Verbindungsabbruch | | `recording.max_retries` | `5` | Max. Reconnect-Versuche bei Verbindungsabbruch (0 = unbegrenzt) |
| `recording.retry_delay` | `5` | Wartezeit (Sekunden) zwischen Reconnect-Versuchen | | `recording.retry_delay` | `5` | Wartezeit (Sekunden) zwischen Reconnect-Versuchen |
### Server
| Parameter | Standard | Beschreibung |
|-----------|----------|-------------|
| `server.host` | `0.0.0.0` | Bind-Adresse des Webservers |
| `server.port` | `8484` | Port des Webservers |
### Authentifizierung ### Authentifizierung
| Parameter | Standard | Beschreibung | | Parameter | Standard | Beschreibung |
@@ -24,6 +33,15 @@ Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine V
| `auth.username` | `admin` | Benutzername | | `auth.username` | `admin` | Benutzername |
| `auth.password` | `stream-recorder` | Passwort | | `auth.password` | `stream-recorder` | Passwort |
### Zeitplan-Puffer
Standardwerte für die Vor- und Nachlaufzeit bei neuen Jobs mit Zeitplan.
| Parameter | Standard | Beschreibung |
|-----------|----------|-------------|
| `schedule.default_pre_buffer` | `5` | Standard-Vorlaufzeit (Minuten) |
| `schedule.default_post_buffer` | `5` | Standard-Nachlaufzeit (Minuten) |
### NTFY ### NTFY
| Parameter | Standard | Beschreibung | | Parameter | Standard | Beschreibung |
@@ -40,6 +58,12 @@ Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine V
| `plik.api_key` | _(leer)_ | API Key für authentifizierte Uploads | | `plik.api_key` | _(leer)_ | API Key für authentifizierte Uploads |
| `plik.ttl` | `30d` | Aufbewahrungsdauer der Uploads (`30d`, `12h`, etc.) | | `plik.ttl` | `30d` | Aufbewahrungsdauer der Uploads (`30d`, `12h`, etc.) |
### Logging
| Parameter | Standard | Beschreibung |
|-----------|----------|-------------|
| `logging.level` | `INFO` | Log-Level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
## Beispiel ## Beispiel
```yaml ```yaml
@@ -48,11 +72,19 @@ recording:
max_retries: 5 max_retries: 5
retry_delay: 5 retry_delay: 5
server:
host: "0.0.0.0"
port: 8484
auth: auth:
enabled: true enabled: true
username: admin username: admin
password: mein-passwort password: mein-passwort
schedule:
default_pre_buffer: 5
default_post_buffer: 5
ntfy: ntfy:
url: https://ntfy.sh/stream-recorder url: https://ntfy.sh/stream-recorder
token: "" token: ""
@@ -62,4 +94,7 @@ plik:
url: https://plik.example.com url: https://plik.example.com
api_key: "" api_key: ""
ttl: 30d ttl: 30d
logging:
level: INFO
``` ```
+2 -30
View File
@@ -42,7 +42,7 @@ Automatischer Upload von Aufnahmen auf einen [Plik](https://github.com/root-gg/p
| Option | Beschreibung | | Option | Beschreibung |
|--------|-------------| |--------|-------------|
| **Plik-Upload** | Upload nach Aufnahme-Ende aktivieren | | **Plik-Upload** | Upload nach Aufnahme-Ende aktivieren |
| **Nach Upload löschen** | Lokale Datei nach erfolgreichem Upload entfernen | | **File nach Upload lokal löschen** | Lokale Datei nach erfolgreichem Upload entfernen |
### Links ### Links
@@ -50,36 +50,8 @@ Nach dem Upload werden zwei URLs generiert:
- **Browser-URL**: `https://plik.example.com/#/?id=UPLOAD_ID` — Übersichtsseite mit In-Browser-Wiedergabe - **Browser-URL**: `https://plik.example.com/#/?id=UPLOAD_ID` — Übersichtsseite mit In-Browser-Wiedergabe
- **Download-URL**: Direkter Datei-Download - **Download-URL**: Direkter Datei-Download
Bei aktiviertem NTFY wird die Browser-URL in der Push-Nachricht mitgeschickt. Die Plik-URL wird in der Datenbank gespeichert und im Dashboard bei den **Letzten Aufnahmen** als klickbarer Link angezeigt. Bei aktiviertem NTFY wird die Browser-URL zusätzlich in der Push-Nachricht mitgeschickt.
### Segment-Upload ### Segment-Upload
Bei Segment-Splitting werden alle Segmente einzeln hochgeladen. Die NTFY-Nachricht enthält die Anzahl der hochgeladenen Segmente. 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.
+34
View File
@@ -0,0 +1,34 @@
# Sender
![Sender](assets/img3_channel.png)
Die Senderbibliothek verwaltet häufig genutzte Streams zentral. Einmal angelegte Sender können in Jobs und Schnellaufnahmen per Dropdown ausgewählt werden — die Stream-URL und der Typ werden automatisch übernommen.
## Sender anlegen
Im Web-UI unter **Sender → + Neuer Sender**.
| Feld | Pflicht | Beschreibung |
|------|---------|-------------|
| **Name** | Ja | Sendername (z.B. `WDR 2`, `DLF Kultur`) |
| **Typ** | Nein | Stream-Typ: `auto` (Standard), RTMP, HLS, MP3, AAC, OGG, HTTP |
| **Stream-URL** | Ja | URL des Streams (z.B. `https://wdr-wdr2-live.icecast.wdr.de/wdr/wdr2/live/mp3/256/stream.mp3`) |
| **Beschreibung** | Nein | Freitext-Notiz (z.B. `Hauptstream, 256 kbit/s`) |
## Sender bearbeiten & löschen
In der Sender-Übersicht stehen pro Eintrag die Buttons **Bearbeiten** und **Löschen** zur Verfügung. Das Löschen eines Senders hat keinen Einfluss auf bereits existierende Jobs — die Stream-URL bleibt im Job erhalten.
## Verwendung in Jobs
Beim Erstellen oder Bearbeiten eines Jobs kann über das Dropdown **Senderbibliothek** ein gespeicherter Sender ausgewählt werden. Dabei werden automatisch übernommen:
- **Stream-URL** und **Typ** — die URL-Eingabe wird ausgeblendet
- **Name** — falls das Namensfeld noch leer ist
- **Beschreibung** — falls das Beschreibungsfeld noch leer ist
Alternativ bleibt die Option **URL manuell eingeben** bestehen.
## Verwendung in Schnellaufnahmen
Auch bei Schnellaufnahmen (Dashboard → + Schnellaufnahme) steht die Senderbibliothek als Dropdown zur Verfügung. Bei Auswahl werden URL, Typ und Name übernommen.