import os import signal import subprocess import threading 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 # --------------------------------------------------------------------------- # 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") ) 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 # --------------------------------------------------------------------------- # 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 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 # --------------------------------------------------------------------------- # 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() 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 # --------------------------------------------------------------------------- # 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 = """
| Start | Datei | Größe | Dauer | Wiedergabe | |
|---|---|---|---|---|---|
| {{ item.datetime }} | {{ item.filename }} | {{ item.size }} | {{ item.duration }} | Download |