1506 lines
44 KiB
Python
1506 lines
44 KiB
Python
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
|
||
|
||
THEME_FILE = Path(os.environ.get("THEME_FILE", "/config/theme.json"))
|
||
SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "/config/settings.json"))
|
||
|
||
DEFAULT_SETTINGS = {
|
||
"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"),
|
||
"edit_page_title": os.environ.get("EDIT_PAGE_TITLE", "Event Audio – Aufnahme bearbeiten"),
|
||
"edit_page_heading": os.environ.get("EDIT_PAGE_HEADING", "Aufnahme bearbeiten"),
|
||
"git_repo_url": os.environ.get("GIT_REPO_URL", ""),
|
||
"user_url": os.environ.get("USER_URL", ""),
|
||
"editor_url": os.environ.get("EDITOR_URL", ""),
|
||
"logo_file": os.environ.get("LOGO_FILE", ""),
|
||
"favicon_file": os.environ.get("FAVICON_FILE", ""),
|
||
}
|
||
|
||
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_settings():
|
||
"""Load UI/branding settings, falling back to environment defaults."""
|
||
settings = dict(DEFAULT_SETTINGS)
|
||
if SETTINGS_FILE.is_file():
|
||
try:
|
||
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||
if isinstance(data, dict):
|
||
for key in DEFAULT_SETTINGS:
|
||
if key in data and isinstance(data[key], str):
|
||
settings[key] = data[key]
|
||
if isinstance(data.get("colors"), dict):
|
||
settings["colors"] = dict(data["colors"])
|
||
except Exception as exc:
|
||
print(f"[branding] Einstellungen konnten nicht geladen werden: {exc}", flush=True)
|
||
return settings
|
||
|
||
|
||
def load_theme():
|
||
"""Load semantic theme colors from settings, then theme.json, then defaults."""
|
||
theme = dict(DEFAULT_THEME)
|
||
|
||
# Keep the existing theme.json as a backwards-compatible fallback.
|
||
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)
|
||
|
||
settings = load_settings()
|
||
colors = settings.get("colors") if isinstance(settings.get("colors"), dict) else {}
|
||
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()
|
||
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():
|
||
settings = load_settings()
|
||
logo_file = Path(settings["logo_file"]) if settings.get("logo_file") else None
|
||
favicon_file = Path(settings["favicon_file"]) if settings.get("favicon_file") else None
|
||
return {
|
||
"site_name": settings["site_name"],
|
||
"site_subtitle": settings["site_subtitle"],
|
||
"page_title": settings["page_title"],
|
||
"page_heading": settings["page_heading"],
|
||
"edit_page_title": settings["edit_page_title"],
|
||
"edit_page_heading": settings["edit_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,
|
||
"git_repo_url": settings["git_repo_url"],
|
||
"user_url": settings["user_url"],
|
||
"editor_url": settings["editor_url"],
|
||
}
|
||
|
||
|
||
def save_settings(data):
|
||
"""Atomically persist the editable page configuration."""
|
||
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
temp = SETTINGS_FILE.with_name(SETTINGS_FILE.name + ".tmp")
|
||
temp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
os.replace(temp, SETTINGS_FILE)
|
||
|
||
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-links { display:flex; justify-content:center; align-items:center; gap:16px; margin-top:8px; }
|
||
.footer-links a { color:var(--color-footer); text-decoration:none; }
|
||
.footer-links a:hover { color:var(--color-text); text-decoration:underline; }
|
||
.git-icon { display:inline-block; margin-right:4px; font-size:16px; line-height:1; }
|
||
|
||
.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: auto;
|
||
}
|
||
}
|
||
|
||
@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">
|
||
<div>Live-Status wird bei Start und Ende einer Aufnahme automatisch aktualisiert.</div>
|
||
{% if git_repo_url or editor_url %}
|
||
<div class="footer-links">
|
||
{% if git_repo_url %}
|
||
<a href="{{ git_repo_url }}" target="_blank" rel="noopener noreferrer">
|
||
<span class="git-icon" aria-hidden="true">⎇</span> App auf Git
|
||
</a>
|
||
{% endif %}
|
||
{% if editor_url %}
|
||
<a href="{{ editor_url }}">Editor</a>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</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():
|
||
settings = load_settings()
|
||
path = Path(settings["logo_file"]) if settings.get("logo_file") else None
|
||
if path is None or not path.is_file():
|
||
abort(404)
|
||
return send_file(path)
|
||
|
||
|
||
@app.route("/branding/favicon")
|
||
def branding_favicon():
|
||
settings = load_settings()
|
||
path = Path(settings["favicon_file"]) if settings.get("favicon_file") else None
|
||
if path is None or not path.is_file():
|
||
abort(404)
|
||
return send_file(path)
|
||
|
||
|
||
@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,
|
||
)
|