Files
event-audio-recorder/recorder.py
T
2026-10-01 22:30:43 +02:00

1432 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
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 = """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<title>{{ page_title }}</title>
{% if favicon_url %}<link rel="icon" href="{{ favicon_url }}">{% endif %}
<style>
{{ theme_css|safe }}
* {
box-sizing: border-box;
}
.site-header { display:flex; align-items:center; gap:18px; margin-bottom:24px; }
.site-logo { max-width:140px; max-height:48px; object-fit:contain; display:block; flex:0 0 auto; }
.site-header h1 { margin:0; }
body {
margin: 0;
padding: 24px;
background: var(--color-background);
color: var(--color-text);
font-family: Arial, Helvetica, sans-serif;
}
h1 {
margin: 0 0 8px 0;
font-size: 28px;
}
.subtitle {
color: var(--color-text-muted);
margin-bottom: 24px;
}
.status {
display: inline-block;
padding: 8px 14px;
border-radius: 6px;
margin-bottom: 24px;
font-weight: bold;
}
.status.recording {
background: var(--color-danger);
color:var(--color-white);
}
.status.idle {
background: var(--color-border);
color: var(--color-text-secondary);
}
.status.error {
background: var(--color-warning);
color:var(--color-white);
}
.recording {
border: 1px solid var(--color-recording);
background: var(--color-live-surface);
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.recording-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 8px;
}
.recording-info {
color: var(--color-text-secondary);
margin-bottom: 12px;
}
.live-panel {
display: flex;
gap: 16px;
border: 1px solid var(--color-recording);
background: var(--color-live-surface);
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.live-item {
flex: 1;
min-width: 0;
}
.live-item + .live-item {
border-left: 1px solid var(--color-recording-border);
padding-left: 16px;
}
.live-title {
font-size: 19px;
font-weight: bold;
margin-bottom: 6px;
}
.live-info {
color: var(--color-text-muted);
font-size: 14px;
margin-bottom: 10px;
}
.filter-bar {
display: grid;
grid-template-columns: minmax(120px, 1fr) minmax(120px, 1fr) minmax(120px, 1fr) minmax(220px, 1.8fr);
gap: 10px;
margin-bottom: 14px;
}
.filter-bar label {
color: var(--color-text-muted);
font-size: 13px;
}
.filter-bar input,
.filter-bar select {
width: 100%;
margin-top: 5px;
padding: 9px;
background: var(--color-background);
color: var(--color-text);
border: 1px solid var(--color-control-hover);
border-radius: 5px;
font-size: 15px;
}
.filter-bar .search {
grid-column: auto;
}
.filter-info {
color: var(--color-text-muted);
font-size: 13px;
margin-bottom: 10px;
}
table {
width: 100%;
border-collapse: collapse;
background: var(--color-surface);
border-radius: 8px;
overflow: hidden;
}
th {
text-align: left;
background: var(--color-table-header);
padding: 12px;
color: var(--color-text-secondary);
}
td {
padding: 12px;
color: var(--color-table-text);
border-top: 1px solid var(--color-border-subtle);
vertical-align: middle;
}
audio {
width: 500px;
max-width: 100%;
height: 48px;
}
/* Die Positionsleiste der nativen Audio-Steuerung besser bedienbar machen.
Chromium/WebKit erlaubt zusätzlich eine direkte Vergrößerung der Timeline. */
audio::-webkit-media-controls-timeline {
height: 8px;
}
audio::-webkit-media-controls-current-time-display,
audio::-webkit-media-controls-time-remaining-display {
font-size: 13px;
}
a.button {
display: inline-block;
padding: 8px 12px;
margin-left: 8px;
background: var(--color-border);
color: var(--color-white);
text-decoration: none;
border-radius: 5px;
}
a.button:hover {
background: var(--color-control-hover);
}
.empty {
padding: 30px;
text-align: center;
color: var(--color-text-faint);
}
.footer {
margin-top: 24px;
color: var(--color-footer);
font-size: 13px;
}
@media (max-width: 900px) {
.filter-bar {
grid-template-columns: 1fr 1fr;
}
.filter-bar .search {
grid-column: span 2;
}
}
@media (max-width:800px) {
body { padding:12px; }
.finished-table, .finished-table tbody, .finished-table tr, .finished-table td { display:block; }
.finished-table { background:transparent; border:0; overflow:visible; }
.finished-table thead { display:none; }
.finished-table tbody { display:grid; gap:18px; }
.finished-table tr { display:grid; grid-template-columns:1fr auto; grid-template-areas:"event date" "title title" "speaker speaker" "duration duration" "audio audio" "actions actions"; gap:0 12px; margin:0; padding:20px; background:var(--color-surface); border:1px solid var(--color-border); border-radius:20px; box-shadow:0 8px 24px var(--color-shadow); }
.finished-table td { border:0; padding:0; min-width:0; }
.finished-table td:nth-child(4) { grid-area:event; justify-self:start; align-self:start; padding:6px 10px; color:var(--color-primary-hover); background:var(--color-selection); border-radius:999px; font-size:12px; font-weight:700; line-height:1.2; }
.finished-table td:nth-child(1) { grid-area:date; justify-self:end; align-self:center; color:var(--color-text-muted); font-size:12px; white-space:nowrap; }
.finished-table td:nth-child(3) { grid-area:title; margin-top:16px; color:var(--color-text); font-size:20px; font-weight:750; line-height:1.25; }
.finished-table td:nth-child(2) { grid-area:speaker; margin-top:6px; color:var(--color-text-muted); font-size:14px; font-weight:600; }
.finished-table td:nth-child(5) { grid-area:duration; margin-top:16px; color:var(--color-text-muted); font-size:13px; }
.finished-table td:nth-child(5)::before { content:"⏱ "; font-size:14px; }
.finished-table td:nth-child(6) { grid-area:audio; margin-top:4px; }
.finished-table td:nth-child(7) { grid-area:actions; margin-top:14px; padding-top:14px; border-top:1px solid var(--color-border-subtle); }
.finished-table audio { width:100%; height:42px; margin:0; }
.finished-table a.button { display:inline-flex; align-items:center; justify-content:center; min-height:44px; padding:0 16px; margin:0; border-radius:12px; }
.live-panel { display:block; }
.live-item + .live-item { border-left:0; border-top:1px solid var(--color-recording-border); padding-left:0; padding-top:16px; margin-top:16px; }
a.button { margin-left:0; margin-right:8px; }
}
#dynamic-content {
background: var(--color-surface-dark);
color: var(--color-white);
border: 1px solid var(--color-primary);
border-radius: 8px;
padding: 16px;
margin: 0 0 24px 0;
box-shadow: 0 2px 6px var(--color-shadow);
}
</style>
</head>
<body>
<div class="site-header">
{% if logo_url %}<img class="site-logo" src="{{ logo_url }}" alt="{{ site_name }}">{% endif %}
<h1>{{ page_heading }}</h1>
</div>
<div class="subtitle">{{ site_subtitle }}</div>
<div id="dynamic-content">
<div id="live-section">
{% if normalizing_filename %}
<div class="status recording">
● AUFNAHME WIRD NORMALISIERT
</div>
<div class="recording">
<div class="recording-title">{{ normalizing_filename }}</div>
<div class="recording-info">Lautstärke wird automatisch normalisiert …</div>
</div>
{% elif recording %}
<div class="status recording">
● AUFNAHME LÄUFT
</div>
<div class="live-panel">
<div class="live-item">
<div class="live-title">🔴 Live-Stream</div>
<div class="live-info">
Direkt vom Icecast-Stream
</div>
<audio controls preload="none">
<source src="{{ icecast_public_url }}"
type="audio/mpeg">
</audio>
</div>
<div class="live-item">
<div class="live-title">▶ Aktuelle Aufnahme</div>
<div class="live-info">
Zeitversetzt · gestartet {{ recording.started }}
· aktuell {{ recording.size }}
</div>
<audio controls>
<source src="/recordings/{{ recording.filename }}?live=1"
type="audio/mpeg">
</audio>
</div>
</div>
{% elif status_error %}
<div class="status error">
⚠ Icecast-Status momentan nicht erreichbar
</div>
{% else %}
<div class="status idle">
● keine laufende Aufnahme
</div>
{% endif %}
</div>
<h2>Fertige Aufnahmen</h2>
<div class="filter-bar">
<label>Zeitraum<select id="dateFilter">
<option value="all">Alle</option>
<option value="today">Heute</option>
<option value="yesterday">Gestern</option>
<option value="7">Letzte 7 Tage</option>
<option value="30">Letzte 30 Tage</option>
<option value="month">Dieser Monat</option>
<option value="lastmonth">Letzter Monat</option>
<option value="year">Dieses Jahr</option>
</select></label>
<label>Redner<select id="artistFilter">
<option value="">Alle Redner</option>
{% for artist in artists %}<option value="{{ artist }}">{{ artist }}</option>{% endfor %}
</select></label>
<label>Veranstaltung<select id="albumFilter">
<option value="">Alle Veranstaltungen</option>
{% for album in albums %}<option value="{{ album }}">{{ album }}</option>{% endfor %}
</select></label>
<label class="search">Beschreibung / Volltext<input id="textFilter" type="search" placeholder="Beschreibung, Redner, Veranstaltung, Dateiname …"></label>
</div>
<div class="filter-info" id="filterInfo"></div>
{% if recordings %}
<table id="finishedTable" class="finished-table">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Redner</th>
<th>Beschreibung</th>
<th>Veranstaltung</th>
<th>Dauer</th>
<th>Wiedergabe</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in recordings %}
<tr class="finished-row"
data-ts="{{ item.sort_ts }}"
data-artist="{{ item.artist }}"
data-album="{{ item.album }}"
data-search="{{ (item.filename ~ ' ' ~ item.artist ~ ' ' ~ item.title ~ ' ' ~ item.album)|lower }}">
<td>{{ item.datetime }}</td>
<td>{{ item.artist or "–" }}</td>
<td>{{ item.title or "–" }}</td>
<td>{{ item.album or "–" }}</td>
<td>{{ item.duration }}</td>
<td>
<audio controls preload="metadata">
<source src="/finished/{{ item.filename }}"
type="audio/mpeg">
</audio>
</td>
<td>
<a class="button"
href="/finished/{{ item.filename }}"
download>
Download
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">
Noch keine fertigen Aufnahmen vorhanden.
</div>
{% endif %}
</div>
<div class="footer">
Live-Status wird bei Start und Ende einer Aufnahme automatisch aktualisiert.
</div>
<script>
function applyFilters() {
const range = document.getElementById('dateFilter').value;
const artist = document.getElementById('artistFilter').value;
const album = document.getElementById('albumFilter').value;
const text = document.getElementById('textFilter').value.trim().toLowerCase();
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
const tomorrow = todayStart + 86400;
const yesterday = todayStart - 86400;
const week = todayStart - 6 * 86400;
const days30 = todayStart - 29 * 86400;
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime() / 1000;
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1).getTime() / 1000;
const lastMonthEnd = monthStart;
const yearStart = new Date(now.getFullYear(), 0, 1).getTime() / 1000;
let visible = 0;
document.querySelectorAll('.finished-row').forEach(row => {
const ts = Number(row.dataset.ts);
let dateOk = true;
if (range === 'today') dateOk = ts >= todayStart && ts < tomorrow;
else if (range === 'yesterday') dateOk = ts >= yesterday && ts < todayStart;
else if (range === '7') dateOk = ts >= week;
else if (range === '30') dateOk = ts >= days30;
else if (range === 'month') dateOk = ts >= monthStart;
else if (range === 'lastmonth') dateOk = ts >= lastMonthStart && ts < lastMonthEnd;
else if (range === 'year') dateOk = ts >= yearStart;
const artistOk = !artist || row.dataset.artist === artist;
const albumOk = !album || row.dataset.album === album;
const textOk = !text || row.dataset.search.includes(text);
const ok = dateOk && artistOk && albumOk && textOk;
row.style.display = ok ? '' : 'none';
if (ok) visible++;
});
const total = document.querySelectorAll('.finished-row').length;
document.getElementById('filterInfo').textContent = `${visible} von ${total} Aufnahmen`;
}
['dateFilter', 'artistFilter', 'albumFilter', 'textFilter'].forEach(id =>
document.getElementById(id)?.addEventListener('input', applyFilters)
);
applyFilters();
// ------------------------------------------------------------
// Wiedergabeposition merken
// ------------------------------------------------------------
// Die Position wird pro Datei und Browser lokal gespeichert.
// Dadurch kann eine Aufnahme später an der zuletzt gehörten Stelle
// fortgesetzt werden, ohne dass dafür ein Server-Login oder eine
// Datenbank notwendig ist.
const PLAYBACK_KEY_PREFIX = 'event-audio-playback:';
const PLAYBACK_SAVE_INTERVAL = 5000;
function playbackKey(audio) {
const source = audio.querySelector('source');
return source
? PLAYBACK_KEY_PREFIX + source.getAttribute('src')
: null;
}
function savePlaybackPosition(audio) {
const key = playbackKey(audio);
if (!key || !Number.isFinite(audio.currentTime) || audio.currentTime <= 0) {
return;
}
try {
localStorage.setItem(key, JSON.stringify({
position: audio.currentTime,
savedAt: Date.now()
}));
} catch (error) {
console.warn('Wiedergabeposition konnte nicht gespeichert werden:', error);
}
}
function restorePlaybackPosition(audio) {
const key = playbackKey(audio);
if (!key) return false;
try {
const saved = JSON.parse(localStorage.getItem(key) || 'null');
if (!saved || !Number.isFinite(saved.position) || saved.position <= 0) {
return false;
}
// Bei preload=none kann loadedmetadata erst beim ersten Abspielen
// eintreten. Deshalb wird diese Funktion auch von play/canplay
// aufgerufen.
if (Number.isFinite(audio.duration) && audio.duration > 0) {
const position = Math.min(saved.position, Math.max(0, audio.duration - 0.5));
if (position > 0) {
audio.currentTime = position;
return true;
}
}
} catch (error) {
console.warn('Wiedergabeposition konnte nicht wiederhergestellt werden:', error);
}
return false;
}
document.querySelectorAll('.finished-row audio').forEach(audio => {
let lastSaved = 0;
let positionRestored = false;
const tryRestorePlaybackPosition = () => {
if (!positionRestored) {
positionRestored = restorePlaybackPosition(audio);
}
};
audio.addEventListener('loadedmetadata', tryRestorePlaybackPosition);
audio.addEventListener('loadeddata', tryRestorePlaybackPosition);
audio.addEventListener('canplay', tryRestorePlaybackPosition);
audio.addEventListener('play', tryRestorePlaybackPosition);
audio.addEventListener('timeupdate', () => {
const now = Date.now();
if (now - lastSaved >= PLAYBACK_SAVE_INTERVAL) {
savePlaybackPosition(audio);
lastSaved = now;
}
});
audio.addEventListener('pause', () => {
if (!audio.ended) {
savePlaybackPosition(audio);
lastSaved = Date.now();
}
});
audio.addEventListener('ended', () => {
const key = playbackKey(audio);
if (!key) return;
try {
localStorage.removeItem(key);
} catch (error) {
console.warn('Gespeicherte Wiedergabeposition konnte nicht gelöscht werden:', error);
}
});
});
window.addEventListener('beforeunload', () => {
document.querySelectorAll('.finished-row audio').forEach(savePlaybackPosition);
});
async function updateLiveSection() {
try {
const response = await fetch("/api/live", { cache: "no-store" });
if (!response.ok) return;
const html = await response.text();
const current = document.getElementById("live-section");
if (current) current.innerHTML = html;
} catch (error) {
console.warn("Live-Status update failed:", error);
}
}
const events = new EventSource("/api/events");
events.onmessage = () => updateLiveSection();
events.onerror = () => { /* EventSource reconnects automatically. */ };
</script>
</body>
</html>
"""
def render_index():
global ffmpeg_process
global recording_filename
global recording_started
global normalizing_filename
files = []
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
for path in FINISHED_DIR.glob("*.mp3"):
try:
stat = path.stat()
tags = read_metadata(path)
files.append({
"filename": path.name,
"datetime": metadata_datetime(path),
"artist": tags.get("artist", ""),
"title": tags.get("title", ""),
"album": tags.get("album", ""),
"size": format_size(stat.st_size),
"duration": format_duration(get_duration(path.name, FINISHED_DIR)),
"sort_date": metadata_sort_key(path),
})
except FileNotFoundError:
continue
for item in files:
item["sort_ts"] = item["sort_date"]
files.sort(key=lambda item: item["sort_date"], reverse=True)
artists = sorted({item["artist"] for item in files if item["artist"]})
albums = sorted({item["album"] for item in files if item["album"]})
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,
artists=artists,
albums=albums,
recording=recording,
normalizing_filename=normalizing_filename,
status_error=status_error,
icecast_public_url=ICECAST_PUBLIC_URL,
**branding_context(),
)
@app.route("/branding/logo")
def branding_logo():
if LOGO_FILE is None or not LOGO_FILE.is_file():
abort(404)
return send_file(LOGO_FILE)
@app.route("/branding/favicon")
def branding_favicon():
if FAVICON_FILE is None or not FAVICON_FILE.is_file():
abort(404)
return send_file(FAVICON_FILE)
@app.route("/")
def index():
return render_index()
@app.route("/api/live")
def api_live():
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 and current_filename:
current_size = "–"
try:
current_size = format_size((RECORDINGS_DIR / current_filename).stat().st_size)
except FileNotFoundError:
pass
recording = {
"filename": current_filename,
"started": format_datetime(current_started),
"size": current_size,
}
return render_template_string(
"""{% if normalizing_filename %}
<div class="status recording">● AUFNAHME WIRD NORMALISIERT</div>
<div class="recording">
<div class="recording-title">{{ normalizing_filename }}</div>
<div class="recording-info">Lautstärke wird automatisch normalisiert …</div>
</div>
{% elif recording %}
<div class="status recording">● AUFNAHME LÄUFT</div>
<div class="live-panel">
<div class="live-item">
<div class="live-title">🔴 Live-Stream</div>
<div class="live-info">Direkt vom Icecast-Stream</div>
<audio controls preload="none"><source src="{{ icecast_public_url }}" type="audio/mpeg"></audio>
</div>
<div class="live-item">
<div class="live-title">▶ Aktuelle Aufnahme</div>
<div class="live-info">Zeitversetzt · gestartet {{ recording.started }} · aktuell {{ recording.size }}</div>
<audio controls><source src="/recordings/{{ recording.filename }}?live=1" type="audio/mpeg"></audio>
</div>
</div>
{% elif status_error %}
<div class="status error">⚠ Icecast-Status momentan nicht erreichbar</div>
{% else %}
<div class="status idle">● keine laufende Aufnahme</div>
{% endif %}""",
recording=recording,
normalizing_filename=normalizing_filename,
status_error=status_error,
icecast_public_url=ICECAST_PUBLIC_URL,
)
@app.route("/api/events")
def api_events():
return Response(
stream_with_context(sse_stream()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@app.route("/api/status")
def api_status():
return render_index()
@app.route("/finished/<path:filename>")
def finished(filename):
return send_from_directory(FINISHED_DIR, filename, as_attachment=False)
@app.route("/recordings/<path:filename>")
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,
)