commit eb1d4388837c8f766a1dc890763cb9479d457659 Author: root Date: Mon Sep 28 16:20:20 2026 +0200 Initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e097048 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.13-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir \ + flask \ + requests \ + gunicorn + +WORKDIR /app + +COPY recorder.py . + +EXPOSE 8080 + +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "1", "--threads", "4", "--timeout", "0", "recorder:app"] \ No newline at end of file diff --git a/recorder.py b/recorder.py new file mode 100644 index 0000000..36ea024 --- /dev/null +++ b/recorder.py @@ -0,0 +1,1005 @@ +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") +) + +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): + path = RECORDINGS_DIR / 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 and replace it only after success.""" + if not NORMALIZE: + return + + source = RECORDINGS_DIR / filename + if not source.exists(): + return + + temp = RECORDINGS_DIR / (filename + ".normalizing.mp3") + + cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", "warning", + "-y", + "-i", str(source), + "-af", + f"loudnorm=I={NORMALIZE_TARGET}:TP={NORMALIZE_TRUE_PEAK}:LRA={NORMALIZE_LRA}", + "-c:a", "libmp3lame", + "-b:a", "128k", + str(temp), + ] + + try: + result = subprocess.run( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + + if result.returncode != 0 or not temp.exists() or temp.stat().st_size == 0: + print( + f"[normalize] failed for {filename}: " + f"{result.stderr.strip()[-1000:]}", + flush=True, + ) + temp.unlink(missing_ok=True) + return + + # Only replace the original after a complete successful conversion. + temp.replace(source) + print(f"[normalize] completed: {filename}", flush=True) + + except Exception as exc: + print(f"[normalize] exception for {filename}: {exc}", flush=True) + temp.unlink(missing_ok=True) + + +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 before it appears + # as the finished recording in the web UI. + if filename and NORMALIZE: + normalizing_filename = filename + 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 = """ + + + + + + + + +Event Audio – Aufnahmen + + + + + + +

EVENT AUDIO – Aufnahmen

+ +
+ Audiostream-Aufzeichnungen +
+ +
+ +{% if normalizing_filename %} +
+ ● AUFNAHME WIRD NORMALISIERT +
+
+
{{ normalizing_filename }}
+
Lautstärke wird automatisch normalisiert …
+
+{% elif recording %} +
+ ● AUFNAHME LÄUFT +
+ +
+
+
🔴 Live-Stream
+
+ Direkt vom Icecast-Stream +
+ +
+ +
+
▶ Aktuelle Aufnahme
+
+ Zeitversetzt · gestartet {{ recording.started }} + · aktuell {{ recording.size }} +
+ +
+
+{% elif status_error %} +
+ ⚠ Icecast-Status momentan nicht erreichbar +
+{% else %} +
+ ● keine laufende Aufnahme +
+{% endif %} + + +

Fertige Aufnahmen

+ +{% if recordings %} + + + + + + + + + + + + + + + +{% for item in recordings %} + + + + + + + + + + + + + + + +{% endfor %} + + +
StartDateiGrößeDauerWiedergabe
{{ item.datetime }}{{ item.filename }}{{ item.size }}{{ item.duration }} + + + + Download + +
+ +{% else %} + +
+ Noch keine fertigen Aufnahmen vorhanden. +
+ +{% endif %} + +
+ + + + + + + +""" + + +def render_index(): + global ffmpeg_process + global recording_filename + global recording_started + global normalizing_filename + + files = [] + + RECORDINGS_DIR.mkdir( + parents=True, + exist_ok=True, + ) + + for path in RECORDINGS_DIR.glob("*.mp3"): + try: + stat = path.stat() + + # Skip the currently active file from the finished list. + if path.name == recording_filename: + continue + + files.append({ + "filename": path.name, + "datetime": format_datetime(stat.st_mtime), + "size": format_size(stat.st_size), + "duration": format_duration( + get_duration(path.name) + ), + "mtime": stat.st_mtime, + }) + + except FileNotFoundError: + continue + + files.sort( + key=lambda item: item["mtime"], + reverse=True, + ) + + with state_lock: + process = ffmpeg_process + current_filename = recording_filename + current_started = recording_started + status_error = not last_status_ok + + recording = None + + if process is not None and process.poll() is None: + current_size = "–" + + if current_filename: + current_path = RECORDINGS_DIR / current_filename + + try: + current_size = format_size( + current_path.stat().st_size + ) + except FileNotFoundError: + pass + + recording = { + "filename": current_filename, + "started": format_datetime(current_started), + "size": current_size, + } + + return render_template_string( + HTML, + recordings=files, + recording=recording, + normalizing_filename=normalizing_filename, + status_error=status_error, + icecast_public_url=ICECAST_PUBLIC_URL, + ) + + +@app.route("/") +def index(): + return render_index() + + +@app.route("/api/status") +def api_status(): + return render_index() + + +@app.route("/recordings/") +def recording(filename): + path = RECORDINGS_DIR / filename + + if not path.is_file(): + abort(404) + + # For the active recording, keep the HTTP connection open and wait for + # additional MP3 data appended by FFmpeg. A normal static-file response + # would expose the current Content-Length and the browser would stop + # when it reaches the file size that existed when playback started. + if request.args.get("live") == "1" and recording_filename == filename and ffmpeg_process is not None: + def generate(): + position = 0 + + while True: + try: + size = path.stat().st_size + except FileNotFoundError: + break + + if position < size: + try: + with path.open("rb") as f: + f.seek(position) + while True: + chunk = f.read(64 * 1024) + if not chunk: + break + position += len(chunk) + yield chunk + except (FileNotFoundError, OSError): + break + continue + + # The recording is still active: wait for FFmpeg to append + # more data instead of closing the HTTP response. + if recording_filename == filename and ffmpeg_process is not None: + time.sleep(0.5) + continue + + # Recording ended; all remaining bytes have been delivered. + break + + return Response( + generate(), + mimetype="audio/mpeg", + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Accept-Ranges": "none", + }, + ) + + return send_from_directory( + RECORDINGS_DIR, + filename, + as_attachment=False, + ) + + +# --------------------------------------------------------------------------- +# Start monitor +# --------------------------------------------------------------------------- + +monitor_thread = threading.Thread( + target=recorder_loop, + daemon=True, +) + +monitor_thread.start() + + +if __name__ == "__main__": + app.run( + host="0.0.0.0", + port=WEB_PORT, + ) \ No newline at end of file