1383 lines
35 KiB
Python
1383 lines
35 KiB
Python
import os
|
||
import signal
|
||
import subprocess
|
||
import threading
|
||
import json
|
||
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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Configuration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
ICECAST_STATUS_URL = os.environ.get(
|
||
"ICECAST_STATUS_URL",
|
||
"http://icecast:8000/status-json.xsl",
|
||
)
|
||
|
||
ICECAST_STREAM_URL = os.environ.get(
|
||
"ICECAST_STREAM_URL",
|
||
"http://icecast:8000/stream.mp3",
|
||
)
|
||
|
||
ICECAST_PUBLIC_URL = os.environ.get(
|
||
"ICECAST_PUBLIC_URL",
|
||
"https://audiostream.diereuthers.de/stream.mp3",
|
||
)
|
||
|
||
STREAM_MOUNTPOINT = os.environ.get(
|
||
"STREAM_MOUNTPOINT",
|
||
"/stream.mp3",
|
||
)
|
||
|
||
RECORDINGS_DIR = Path(
|
||
os.environ.get("RECORDINGS_DIR", "/recordings")
|
||
)
|
||
|
||
FINISHED_DIR = Path(
|
||
os.environ.get("FINISHED_DIR", "/finished")
|
||
)
|
||
|
||
WEB_PORT = int(
|
||
os.environ.get("WEB_PORT", "8080")
|
||
)
|
||
|
||
TIMEZONE = ZoneInfo(
|
||
os.environ.get("TIMEZONE", "Europe/Berlin")
|
||
)
|
||
|
||
START_DELAY = float(
|
||
os.environ.get("START_DELAY", "3")
|
||
)
|
||
|
||
STOP_DELAY = float(
|
||
os.environ.get("STOP_DELAY", "10")
|
||
)
|
||
|
||
POLL_INTERVAL = float(
|
||
os.environ.get("POLL_INTERVAL", "2")
|
||
)
|
||
|
||
NORMALIZE = os.environ.get("NORMALIZE", "true").lower() not in ("0", "false", "no")
|
||
NORMALIZE_TARGET = os.environ.get("NORMALIZE_TARGET", "-16")
|
||
NORMALIZE_TRUE_PEAK = os.environ.get("NORMALIZE_TRUE_PEAK", "-1.5")
|
||
NORMALIZE_LRA = os.environ.get("NORMALIZE_LRA", "11")
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Application state
|
||
# ---------------------------------------------------------------------------
|
||
|
||
app = Flask(__name__)
|
||
|
||
state_lock = threading.Lock()
|
||
|
||
ffmpeg_process = None
|
||
recording_filename = None
|
||
recording_started = None
|
||
normalizing_filename = None
|
||
|
||
source_seen_since = None
|
||
source_missing_since = None
|
||
|
||
last_status_ok = False
|
||
last_status_check = None
|
||
last_status_error = None
|
||
|
||
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>Event Audio – Aufnahmen</title>
|
||
|
||
<style>
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
padding: 24px;
|
||
background: #111;
|
||
color: #eee;
|
||
font-family: Arial, Helvetica, sans-serif;
|
||
}
|
||
|
||
h1 {
|
||
margin: 0 0 8px 0;
|
||
font-size: 28px;
|
||
}
|
||
|
||
.subtitle {
|
||
color: #aaa;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.status {
|
||
display: inline-block;
|
||
padding: 8px 14px;
|
||
border-radius: 6px;
|
||
margin-bottom: 24px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.status.recording {
|
||
background: #8b0000;
|
||
color: white;
|
||
}
|
||
|
||
.status.idle {
|
||
background: #333;
|
||
color: #ccc;
|
||
}
|
||
|
||
.status.error {
|
||
background: #6b4e00;
|
||
color: white;
|
||
}
|
||
|
||
.recording {
|
||
border: 1px solid #b00000;
|
||
background: #1d1111;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.recording-title {
|
||
font-size: 20px;
|
||
font-weight: bold;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.recording-info {
|
||
color: #bbb;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.live-panel {
|
||
display: flex;
|
||
gap: 16px;
|
||
border: 1px solid #b00000;
|
||
background: #1d1111;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.live-item {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.live-item + .live-item {
|
||
border-left: 1px solid #3a2222;
|
||
padding-left: 16px;
|
||
}
|
||
|
||
.live-title {
|
||
font-size: 19px;
|
||
font-weight: bold;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.live-info {
|
||
color: #aaa;
|
||
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: #aaa;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.filter-bar input,
|
||
.filter-bar select {
|
||
width: 100%;
|
||
margin-top: 5px;
|
||
padding: 9px;
|
||
background: #111;
|
||
color: #eee;
|
||
border: 1px solid #555;
|
||
border-radius: 5px;
|
||
font-size: 15px;
|
||
}
|
||
|
||
.filter-bar .search {
|
||
grid-column: auto;
|
||
}
|
||
|
||
.filter-info {
|
||
color: #999;
|
||
font-size: 13px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
background: #181818;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
th {
|
||
text-align: left;
|
||
background: #252525;
|
||
padding: 12px;
|
||
color: #ccc;
|
||
}
|
||
|
||
td {
|
||
padding: 12px;
|
||
border-top: 1px solid #2b2b2b;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
audio {
|
||
width: 280px;
|
||
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: #333;
|
||
color: #fff;
|
||
text-decoration: none;
|
||
border-radius: 5px;
|
||
}
|
||
|
||
a.button:hover {
|
||
background: #444;
|
||
}
|
||
|
||
.empty {
|
||
padding: 30px;
|
||
text-align: center;
|
||
color: #777;
|
||
}
|
||
|
||
.footer {
|
||
margin-top: 24px;
|
||
color: #666;
|
||
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;
|
||
}
|
||
|
||
table, thead, tbody, th, td, tr {
|
||
display: block;
|
||
}
|
||
|
||
thead {
|
||
display: none;
|
||
}
|
||
|
||
tr {
|
||
border-bottom: 1px solid #333;
|
||
padding: 12px 0;
|
||
}
|
||
|
||
td {
|
||
border: 0;
|
||
padding: 6px 0;
|
||
}
|
||
|
||
audio {
|
||
width: 100%;
|
||
}
|
||
|
||
.live-panel {
|
||
display: block;
|
||
}
|
||
|
||
.live-item + .live-item {
|
||
border-left: 0;
|
||
border-top: 1px solid #3a2222;
|
||
padding-left: 0;
|
||
padding-top: 16px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
a.button {
|
||
margin-left: 0;
|
||
margin-right: 8px;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
|
||
<h1>EVENT AUDIO – Aufnahmen</h1>
|
||
|
||
<div class="subtitle">
|
||
Audiostream-Aufzeichnungen
|
||
</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">
|
||
<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,
|
||
)
|
||
|
||
|
||
@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,
|
||
) |