Release: v3.1.0
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user