""" 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)