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

This commit is contained in:
Patrick Asmus
2026-09-16 20:22:34 +02:00
parent ce1ccb91bb
commit a9a9a89cba
26 changed files with 552 additions and 332 deletions
-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)
+16 -10
View File
@@ -95,7 +95,8 @@ class PlikUploader:
self._notify = notify_fn
def upload_recording(self, output_dir: str, output_file: str, job_name: str,
segment_mode: bool, delete_after: bool, ntfy_enabled: bool):
segment_mode: bool, delete_after: bool, ntfy_enabled: bool,
save_url_fn: Callable = None):
plik_url = self.config.get("plik", "url", default="")
if not plik_url:
self._log("WARN", f"Plik-Upload übersprungen: Keine URL konfiguriert [{job_name}]", job_name)
@@ -107,15 +108,15 @@ class PlikUploader:
def _do_upload():
try:
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:
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:
self._log("ERROR", f"Plik-Upload Fehler: {e} [{job_name}]", job_name)
threading.Thread(target=_do_upload, daemon=True).start()
def _upload_single(self, file_path, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled):
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:
self._log("WARN", f"Plik-Upload übersprungen: Datei fehlt oder leer [{job_name}]", job_name)
return
@@ -127,6 +128,8 @@ class PlikUploader:
self._log("INFO", f"Plik-Upload abgeschlossen: {result['browser_url']} [{job_name}]", job_name)
if result.get("download_url"):
self._log("INFO", f"Plik-Download: {result['download_url']} [{job_name}]", job_name)
if save_url_fn:
save_url_fn(result["browser_url"])
if self._notify:
self._notify("upload", "Plik-Upload", f"{job_name}: {result['browser_url']}", ntfy_enabled)
if delete_after:
@@ -135,7 +138,7 @@ class PlikUploader:
else:
self._log("ERROR", f"Plik-Upload fehlgeschlagen [{job_name}]", job_name)
def _upload_segments(self, output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled):
def _upload_segments(self, output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn=None):
if not os.path.isdir(output_dir):
return
@@ -161,8 +164,11 @@ class PlikUploader:
else:
self._log("ERROR", f"Plik Segment-Upload fehlgeschlagen: {fname} [{job_name}]", job_name)
if uploaded > 0 and self._notify:
msg = f"{job_name}: {uploaded} Segment(e) hochgeladen"
if last_browser_url:
msg += f"\n{last_browser_url}"
self._notify("upload", "Plik-Upload", msg, ntfy_enabled)
if uploaded > 0:
if save_url_fn and last_browser_url:
save_url_fn(last_browser_url)
if self._notify:
msg = f"{job_name}: {uploaded} Segment(e) hochgeladen"
if last_browser_url:
msg += f"\n{last_browser_url}"
self._notify("upload", "Plik-Upload", msg, ntfy_enabled)