import os import signal import subprocess import threading import json import re import time from datetime import datetime from pathlib import Path from zoneinfo import ZoneInfo import requests from flask import Flask, render_template_string, send_from_directory, abort, Response, request, stream_with_context, send_file # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- ICECAST_STATUS_URL = os.environ.get( "ICECAST_STATUS_URL", "http://icecast:8000/status-json.xsl", ) ICECAST_STREAM_URL = os.environ.get( "ICECAST_STREAM_URL", "http://icecast:8000/stream.mp3", ) ICECAST_PUBLIC_URL = os.environ.get( "ICECAST_PUBLIC_URL", "https://audiostream.diereuthers.de/stream.mp3", ) STREAM_MOUNTPOINT = os.environ.get( "STREAM_MOUNTPOINT", "/stream.mp3", ) RECORDINGS_DIR = Path( os.environ.get("RECORDINGS_DIR", "/recordings") ) FINISHED_DIR = Path( os.environ.get("FINISHED_DIR", "/finished") ) WEB_PORT = int( os.environ.get("WEB_PORT", "8080") ) TIMEZONE = ZoneInfo(os.environ.get("TIMEZONE", "Europe/Berlin")) START_DELAY = float(os.environ.get("START_DELAY", "3")) STOP_DELAY = float(os.environ.get("STOP_DELAY", "10")) POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL", "2")) # --------------------------------------------------------------------------- # Branding / theme configuration SITE_NAME = os.environ.get("SITE_NAME", "Event Audio") SITE_SUBTITLE = os.environ.get("SITE_SUBTITLE", "Audiostream-Aufzeichnungen") PAGE_TITLE = os.environ.get("PAGE_TITLE", "Event Audio – Aufnahmen") PAGE_HEADING = os.environ.get("PAGE_HEADING", "EVENT AUDIO – Aufnahmen") THEME_FILE = Path(os.environ.get("THEME_FILE", "/config/theme.json")) LOGO_FILE = Path(os.environ["LOGO_FILE"]) if os.environ.get("LOGO_FILE") else None FAVICON_FILE = Path(os.environ["FAVICON_FILE"]) if os.environ.get("FAVICON_FILE") else None GIT_REPO_URL = os.environ.get("GIT_REPO_URL", "") EDITOR_URL = os.environ.get("EDITOR_URL", "") DEFAULT_THEME = {'background': '#111', 'surface': '#181818', 'surface_alt': '#222', 'surface_dark': '#0b0b0b', 'text': '#eee', 'text_secondary': '#ccc', 'text_muted': '#aaa', 'text_faint': '#777', 'border': '#333', 'border_subtle': '#2b2b2b', 'control': '#333', 'control_hover': '#444', 'primary': '#1769aa', 'primary_hover': '#1f7fc2', 'accent': '#ffcc33', 'success': '#173d24', 'success_text': '#b8f5c9', 'warning': '#6b4e00', 'danger': '#8b0000', 'danger_soft': '#4a2020', 'danger_soft_hover': '#6a2b2b', 'error': '#4a1b1b', 'error_text': '#ffd0d0', 'recording': '#b00000', 'recording_border': '#3a2222', 'selection': 'rgba(80,150,255,.16)', 'shadow': '#000', 'scroll_thumb': '#777', 'scroll_thumb_hover': '#999', 'scroll_thumb_active': '#aaa', 'live_surface': '#1d1111', 'table_header': '#252525', 'table_text': '#eee', 'footer': '#666', 'white': '#fff','surface_modal':'#202020','overlay':'rgba(0,0,0,.78)'} def load_theme(): """Load semantic theme colors from JSON, falling back to built-in defaults.""" theme = dict(DEFAULT_THEME) if THEME_FILE.is_file(): try: data = json.loads(THEME_FILE.read_text(encoding="utf-8")) colors = data.get("colors", data) if isinstance(data, dict) else {} if isinstance(colors, dict): for key in theme: value = colors.get(key) if isinstance(value, str) and re.fullmatch(r"(?:#[0-9a-fA-F]{3,8}|rgba?\([^)]*\))", value.strip()): theme[key] = value.strip() except Exception as exc: print(f"[branding] Theme konnte nicht geladen werden: {exc}", flush=True) return theme def theme_css(): theme = load_theme() return ":root {\n" + "\n".join( f" --color-{key.replace('_', '-')}: {value};" for key, value in theme.items() ) + "\n}" def branding_context(): return { "site_name": SITE_NAME, "site_subtitle": SITE_SUBTITLE, "page_title": PAGE_TITLE, "page_heading": PAGE_HEADING, "theme_css": theme_css(), "logo_url": "/branding/logo" if LOGO_FILE and LOGO_FILE.is_file() else None, "favicon_url": "/branding/favicon" if FAVICON_FILE and FAVICON_FILE.is_file() else None, } NORMALIZE = os.environ.get("NORMALIZE", "true").lower() not in ("0", "false", "no") NORMALIZE_TARGET = os.environ.get("NORMALIZE_TARGET", "-16") NORMALIZE_TRUE_PEAK = os.environ.get("NORMALIZE_TRUE_PEAK", "-1.5") NORMALIZE_LRA = os.environ.get("NORMALIZE_LRA", "11") # --------------------------------------------------------------------------- # Application state # --------------------------------------------------------------------------- app = Flask(__name__) state_lock = threading.Lock() ffmpeg_process = None recording_filename = None recording_started = None normalizing_filename = None source_seen_since = None source_missing_since = None last_status_ok = False last_status_check = None last_status_error = None SSE_CLIENTS = set() SSE_LOCK = threading.Lock() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def now(): return datetime.now(TIMEZONE) def format_datetime(timestamp): if not timestamp: return "–" return datetime.fromtimestamp( timestamp, TIMEZONE ).strftime("%d.%m.%Y %H:%M:%S") def format_size(size): if size < 1024: return f"{size} B" if size < 1024 ** 2: return f"{size / 1024:.1f} KB" if size < 1024 ** 3: return f"{size / 1024 ** 2:.1f} MB" return f"{size / 1024 ** 3:.2f} GB" def format_duration(seconds): if seconds is None: return "–" seconds = int(seconds) hours = seconds // 3600 minutes = (seconds % 3600) // 60 seconds = seconds % 60 if hours: return f"{hours}:{minutes:02d}:{seconds:02d}" return f"{minutes}:{seconds:02d}" def read_metadata(path): """Read common ID3/FFmpeg metadata from an MP3.""" try: result = subprocess.run( ["ffprobe", "-v", "error", "-show_entries", "format_tags=date,artist,title,album", "-of", "json", str(path)], capture_output=True, text=True, timeout=10, ) if result.returncode != 0: return {} data = json.loads(result.stdout) tags = data.get("format", {}).get("tags", {}) or {} return {str(k).lower(): str(v) for k, v in tags.items() if v is not None} except Exception: return {} def metadata_datetime(path): tags = read_metadata(path) value = tags.get("date", "") if value: try: dt = datetime.fromisoformat(value.replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=TIMEZONE) return dt.astimezone(TIMEZONE).strftime("%d.%m.%Y %H:%M") except ValueError: pass return format_datetime(path.stat().st_mtime) def metadata_sort_key(path): tags = read_metadata(path) value = tags.get("date", "") if value: try: dt = datetime.fromisoformat(value.replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=TIMEZONE) return dt.timestamp() except ValueError: pass return path.stat().st_mtime def get_duration(filename, directory=RECORDINGS_DIR): path = directory / filename try: result = subprocess.run( [ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(path), ], capture_output=True, text=True, timeout=5, ) if result.returncode != 0: return None return float(result.stdout.strip()) except Exception: return None def is_stream_active(data): """ Detect the configured Icecast mountpoint. Icecast normally returns a dict for one source and can return a list when multiple sources exist. """ try: source = data["icestats"]["source"] except (KeyError, TypeError): return False if not source: return False if isinstance(source, dict): sources = [source] elif isinstance(source, list): sources = source else: return False for item in sources: if not isinstance(item, dict): continue listenurl = item.get("listenurl", "") # Icecast returns the public listen URL, so compare the path. if listenurl: try: from urllib.parse import urlparse path = urlparse(listenurl).path if path == STREAM_MOUNTPOINT: return True except Exception: pass return False def query_icecast(): global last_status_ok global last_status_check global last_status_error try: response = requests.get( ICECAST_STATUS_URL, timeout=3, ) response.raise_for_status() data = response.json() with state_lock: last_status_ok = True last_status_check = time.time() last_status_error = None return is_stream_active(data) except Exception as exc: with state_lock: last_status_ok = False last_status_check = time.time() last_status_error = str(exc) print( f"[monitor] Icecast status check failed: {exc}", flush=True, ) # Important: # A temporary status failure must NOT be interpreted as # "stream stopped". return None def broadcast_event(event, filename=None): payload = json.dumps({ "event": event, "filename": filename, }, ensure_ascii=False) dead = [] with SSE_LOCK: for client_queue in list(SSE_CLIENTS): try: client_queue.put_nowait(payload) except Exception: dead.append(client_queue) for client_queue in dead: SSE_CLIENTS.discard(client_queue) def sse_stream(): import queue client_queue = queue.Queue() with SSE_LOCK: SSE_CLIENTS.add(client_queue) try: yield ": connected\n\n" while True: try: payload = client_queue.get(timeout=20) yield f"data: {payload}\n\n" except queue.Empty: yield ": keepalive\n\n" finally: with SSE_LOCK: SSE_CLIENTS.discard(client_queue) # --------------------------------------------------------------------------- # Recording # --------------------------------------------------------------------------- def start_recording(): global ffmpeg_process global recording_filename global recording_started timestamp = now().strftime("%Y-%m-%d_%H-%M-%S") filename = f"{timestamp}.mp3" output = RECORDINGS_DIR / filename RECORDINGS_DIR.mkdir( parents=True, exist_ok=True, ) print( f"[recorder] Starting recording: {filename}", flush=True, ) ffmpeg_process = subprocess.Popen( [ "ffmpeg", "-hide_banner", "-loglevel", "warning", "-i", ICECAST_STREAM_URL, "-c:a", "copy", "-y", str(output), ] ) recording_filename = filename recording_started = time.time() broadcast_event("recording_started", filename) def normalize_recording(filename): """Normalize a completed MP3 in place in RECORDINGS_DIR.""" source = RECORDINGS_DIR / filename if not source.exists(): return False if not NORMALIZE: print(f"[normalize] disabled: {filename}", flush=True) return True temp = RECORDINGS_DIR / (filename + ".normalizing.mp3") cmd = [ "ffmpeg", "-hide_banner", "-loglevel", "warning", "-y", "-i", str(source), "-af", ( f"loudnorm=I={NORMALIZE_TARGET}:" f"TP={NORMALIZE_TRUE_PEAK}:" f"LRA={NORMALIZE_LRA}" ), "-c:a", "libmp3lame", "-b:a", "128k", str(temp), ] print(f"[normalize] starting: {filename}", flush=True) try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=1800, ) if result.returncode != 0: print( f"[normalize] ffmpeg failed ({result.returncode}): " f"{result.stderr[-2000:]}", flush=True, ) temp.unlink(missing_ok=True) return False temp.replace(source) print(f"[normalize] completed: {filename}", flush=True) return True except Exception as exc: print(f"[normalize] error: {exc}", flush=True) temp.unlink(missing_ok=True) return False def stop_recording(): global ffmpeg_process global recording_filename global recording_started global normalizing_filename if ffmpeg_process is None: return filename = recording_filename print( f"[recorder] Stopping recording: {filename}", flush=True, ) try: ffmpeg_process.send_signal(signal.SIGINT) ffmpeg_process.wait(timeout=15) except subprocess.TimeoutExpired: print( "[recorder] FFmpeg did not stop cleanly, terminating", flush=True, ) ffmpeg_process.terminate() try: ffmpeg_process.wait(timeout=5) except subprocess.TimeoutExpired: ffmpeg_process.kill() except Exception as exc: print( f"[recorder] Error stopping FFmpeg: {exc}", flush=True, ) ffmpeg_process = None recording_filename = None recording_started = None # The live recording is now complete. Normalize it in RECORDINGS_DIR. if filename: normalizing_filename = filename if NORMALIZE else None try: normalize_recording(filename) finally: normalizing_filename = None broadcast_event("recording_stopped", filename) # --------------------------------------------------------------------------- # Monitoring loop # --------------------------------------------------------------------------- def recorder_loop(): global ffmpeg_process global recording_filename global recording_started global source_seen_since global source_missing_since print("[monitor] Recorder monitor started", flush=True) while True: active = query_icecast() now_ts = time.time() with state_lock: process = ffmpeg_process current_filename = recording_filename if active is None: time.sleep(POLL_INTERVAL) continue # FFmpeg ending after the Icecast source disappeared is a normal # end of the recording. Finalize it so normalization is performed. if process is not None and process.poll() is not None: if not active: print( f"[recorder] FFmpeg ended because the Icecast stream stopped: {current_filename}", flush=True, ) stop_recording() source_seen_since = None source_missing_since = None time.sleep(POLL_INTERVAL) continue print( "[recorder] FFmpeg exited unexpectedly while Icecast is active", flush=True, ) ffmpeg_process = None recording_filename = None recording_started = None source_seen_since = now_ts time.sleep(POLL_INTERVAL) continue if active: source_missing_since = None if process is None: if source_seen_since is None: source_seen_since = now_ts elif now_ts - source_seen_since >= START_DELAY: start_recording() source_seen_since = None else: source_seen_since = None else: source_seen_since = None if process is not None: if source_missing_since is None: source_missing_since = now_ts elif now_ts - source_missing_since >= STOP_DELAY: stop_recording() source_missing_since = None else: source_missing_since = None time.sleep(POLL_INTERVAL) # --------------------------------------------------------------------------- # Web UI # --------------------------------------------------------------------------- HTML = """
| Zeitpunkt | Redner | Beschreibung | Veranstaltung | Dauer | Wiedergabe | |
|---|---|---|---|---|---|---|
| {{ item.datetime }} | {{ item.artist or "–" }} | {{ item.title or "–" }} | {{ item.album or "–" }} | {{ item.duration }} | Download |