1005 lines
22 KiB
Python
1005 lines
22 KiB
Python
import os
|
||
import signal
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import requests
|
||
from flask import Flask, render_template_string, send_from_directory, abort, Response, request
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Configuration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
ICECAST_STATUS_URL = os.environ.get(
|
||
"ICECAST_STATUS_URL",
|
||
"http://icecast:8000/status-json.xsl",
|
||
)
|
||
|
||
ICECAST_STREAM_URL = os.environ.get(
|
||
"ICECAST_STREAM_URL",
|
||
"http://icecast:8000/stream.mp3",
|
||
)
|
||
|
||
ICECAST_PUBLIC_URL = os.environ.get(
|
||
"ICECAST_PUBLIC_URL",
|
||
"https://audiostream.diereuthers.de/stream.mp3",
|
||
)
|
||
|
||
STREAM_MOUNTPOINT = os.environ.get(
|
||
"STREAM_MOUNTPOINT",
|
||
"/stream.mp3",
|
||
)
|
||
|
||
RECORDINGS_DIR = Path(
|
||
os.environ.get("RECORDINGS_DIR", "/recordings")
|
||
)
|
||
|
||
WEB_PORT = int(
|
||
os.environ.get("WEB_PORT", "8080")
|
||
)
|
||
|
||
TIMEZONE = ZoneInfo(
|
||
os.environ.get("TIMEZONE", "Europe/Berlin")
|
||
)
|
||
|
||
START_DELAY = float(
|
||
os.environ.get("START_DELAY", "3")
|
||
)
|
||
|
||
STOP_DELAY = float(
|
||
os.environ.get("STOP_DELAY", "10")
|
||
)
|
||
|
||
POLL_INTERVAL = float(
|
||
os.environ.get("POLL_INTERVAL", "2")
|
||
)
|
||
|
||
NORMALIZE = os.environ.get("NORMALIZE", "true").lower() not in ("0", "false", "no")
|
||
NORMALIZE_TARGET = os.environ.get("NORMALIZE_TARGET", "-16")
|
||
NORMALIZE_TRUE_PEAK = os.environ.get("NORMALIZE_TRUE_PEAK", "-1.5")
|
||
NORMALIZE_LRA = os.environ.get("NORMALIZE_LRA", "11")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Application state
|
||
# ---------------------------------------------------------------------------
|
||
|
||
app = Flask(__name__)
|
||
|
||
state_lock = threading.Lock()
|
||
|
||
ffmpeg_process = None
|
||
recording_filename = None
|
||
recording_started = None
|
||
normalizing_filename = None
|
||
|
||
source_seen_since = None
|
||
source_missing_since = None
|
||
|
||
last_status_ok = False
|
||
last_status_check = None
|
||
last_status_error = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def now():
|
||
return datetime.now(TIMEZONE)
|
||
|
||
|
||
def format_datetime(timestamp):
|
||
if not timestamp:
|
||
return "–"
|
||
|
||
return datetime.fromtimestamp(
|
||
timestamp,
|
||
TIMEZONE
|
||
).strftime("%d.%m.%Y %H:%M:%S")
|
||
|
||
|
||
def format_size(size):
|
||
if size < 1024:
|
||
return f"{size} B"
|
||
|
||
if size < 1024 ** 2:
|
||
return f"{size / 1024:.1f} KB"
|
||
|
||
if size < 1024 ** 3:
|
||
return f"{size / 1024 ** 2:.1f} MB"
|
||
|
||
return f"{size / 1024 ** 3:.2f} GB"
|
||
|
||
|
||
def format_duration(seconds):
|
||
if seconds is None:
|
||
return "–"
|
||
|
||
seconds = int(seconds)
|
||
|
||
hours = seconds // 3600
|
||
minutes = (seconds % 3600) // 60
|
||
seconds = seconds % 60
|
||
|
||
if hours:
|
||
return f"{hours}:{minutes:02d}:{seconds:02d}"
|
||
|
||
return f"{minutes}:{seconds:02d}"
|
||
|
||
|
||
def get_duration(filename):
|
||
path = RECORDINGS_DIR / filename
|
||
|
||
try:
|
||
result = subprocess.run(
|
||
[
|
||
"ffprobe",
|
||
"-v", "error",
|
||
"-show_entries",
|
||
"format=duration",
|
||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||
str(path),
|
||
],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=5,
|
||
)
|
||
|
||
if result.returncode != 0:
|
||
return None
|
||
|
||
return float(result.stdout.strip())
|
||
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def is_stream_active(data):
|
||
"""
|
||
Detect the configured Icecast mountpoint.
|
||
|
||
Icecast normally returns a dict for one source and can return
|
||
a list when multiple sources exist.
|
||
"""
|
||
|
||
try:
|
||
source = data["icestats"]["source"]
|
||
except (KeyError, TypeError):
|
||
return False
|
||
|
||
if not source:
|
||
return False
|
||
|
||
if isinstance(source, dict):
|
||
sources = [source]
|
||
elif isinstance(source, list):
|
||
sources = source
|
||
else:
|
||
return False
|
||
|
||
for item in sources:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
|
||
listenurl = item.get("listenurl", "")
|
||
|
||
# Icecast returns the public listen URL, so compare the path.
|
||
if listenurl:
|
||
try:
|
||
from urllib.parse import urlparse
|
||
|
||
path = urlparse(listenurl).path
|
||
|
||
if path == STREAM_MOUNTPOINT:
|
||
return True
|
||
|
||
except Exception:
|
||
pass
|
||
|
||
return False
|
||
|
||
|
||
def query_icecast():
|
||
global last_status_ok
|
||
global last_status_check
|
||
global last_status_error
|
||
|
||
try:
|
||
response = requests.get(
|
||
ICECAST_STATUS_URL,
|
||
timeout=3,
|
||
)
|
||
|
||
response.raise_for_status()
|
||
|
||
data = response.json()
|
||
|
||
with state_lock:
|
||
last_status_ok = True
|
||
last_status_check = time.time()
|
||
last_status_error = None
|
||
|
||
return is_stream_active(data)
|
||
|
||
except Exception as exc:
|
||
with state_lock:
|
||
last_status_ok = False
|
||
last_status_check = time.time()
|
||
last_status_error = str(exc)
|
||
|
||
print(
|
||
f"[monitor] Icecast status check failed: {exc}",
|
||
flush=True,
|
||
)
|
||
|
||
# Important:
|
||
# A temporary status failure must NOT be interpreted as
|
||
# "stream stopped".
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Recording
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def start_recording():
|
||
global ffmpeg_process
|
||
global recording_filename
|
||
global recording_started
|
||
|
||
timestamp = now().strftime("%Y-%m-%d_%H-%M-%S")
|
||
filename = f"{timestamp}.mp3"
|
||
|
||
output = RECORDINGS_DIR / filename
|
||
|
||
RECORDINGS_DIR.mkdir(
|
||
parents=True,
|
||
exist_ok=True,
|
||
)
|
||
|
||
print(
|
||
f"[recorder] Starting recording: {filename}",
|
||
flush=True,
|
||
)
|
||
|
||
ffmpeg_process = subprocess.Popen(
|
||
[
|
||
"ffmpeg",
|
||
"-hide_banner",
|
||
"-loglevel", "warning",
|
||
"-i", ICECAST_STREAM_URL,
|
||
"-c:a", "copy",
|
||
"-y",
|
||
str(output),
|
||
]
|
||
)
|
||
|
||
recording_filename = filename
|
||
recording_started = time.time()
|
||
|
||
|
||
def normalize_recording(filename):
|
||
"""Normalize a completed MP3 and replace it only after success."""
|
||
if not NORMALIZE:
|
||
return
|
||
|
||
source = RECORDINGS_DIR / filename
|
||
if not source.exists():
|
||
return
|
||
|
||
temp = RECORDINGS_DIR / (filename + ".normalizing.mp3")
|
||
|
||
cmd = [
|
||
"ffmpeg",
|
||
"-hide_banner",
|
||
"-loglevel", "warning",
|
||
"-y",
|
||
"-i", str(source),
|
||
"-af",
|
||
f"loudnorm=I={NORMALIZE_TARGET}:TP={NORMALIZE_TRUE_PEAK}:LRA={NORMALIZE_LRA}",
|
||
"-c:a", "libmp3lame",
|
||
"-b:a", "128k",
|
||
str(temp),
|
||
]
|
||
|
||
try:
|
||
result = subprocess.run(
|
||
cmd,
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
check=False,
|
||
)
|
||
|
||
if result.returncode != 0 or not temp.exists() or temp.stat().st_size == 0:
|
||
print(
|
||
f"[normalize] failed for {filename}: "
|
||
f"{result.stderr.strip()[-1000:]}",
|
||
flush=True,
|
||
)
|
||
temp.unlink(missing_ok=True)
|
||
return
|
||
|
||
# Only replace the original after a complete successful conversion.
|
||
temp.replace(source)
|
||
print(f"[normalize] completed: {filename}", flush=True)
|
||
|
||
except Exception as exc:
|
||
print(f"[normalize] exception for {filename}: {exc}", flush=True)
|
||
temp.unlink(missing_ok=True)
|
||
|
||
|
||
def stop_recording():
|
||
global ffmpeg_process
|
||
global recording_filename
|
||
global recording_started
|
||
global normalizing_filename
|
||
|
||
if ffmpeg_process is None:
|
||
return
|
||
|
||
filename = recording_filename
|
||
|
||
print(
|
||
f"[recorder] Stopping recording: {filename}",
|
||
flush=True,
|
||
)
|
||
|
||
try:
|
||
ffmpeg_process.send_signal(signal.SIGINT)
|
||
ffmpeg_process.wait(timeout=15)
|
||
|
||
except subprocess.TimeoutExpired:
|
||
print(
|
||
"[recorder] FFmpeg did not stop cleanly, terminating",
|
||
flush=True,
|
||
)
|
||
|
||
ffmpeg_process.terminate()
|
||
|
||
try:
|
||
ffmpeg_process.wait(timeout=5)
|
||
except subprocess.TimeoutExpired:
|
||
ffmpeg_process.kill()
|
||
|
||
except Exception as exc:
|
||
print(
|
||
f"[recorder] Error stopping FFmpeg: {exc}",
|
||
flush=True,
|
||
)
|
||
|
||
ffmpeg_process = None
|
||
recording_filename = None
|
||
recording_started = None
|
||
|
||
# The live recording is now complete. Normalize it before it appears
|
||
# as the finished recording in the web UI.
|
||
if filename and NORMALIZE:
|
||
normalizing_filename = filename
|
||
try:
|
||
normalize_recording(filename)
|
||
finally:
|
||
normalizing_filename = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Monitoring loop
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def recorder_loop():
|
||
global ffmpeg_process
|
||
global recording_filename
|
||
global recording_started
|
||
global source_seen_since
|
||
global source_missing_since
|
||
|
||
print("[monitor] Recorder monitor started", flush=True)
|
||
|
||
while True:
|
||
active = query_icecast()
|
||
now_ts = time.time()
|
||
|
||
with state_lock:
|
||
process = ffmpeg_process
|
||
current_filename = recording_filename
|
||
|
||
if active is None:
|
||
time.sleep(POLL_INTERVAL)
|
||
continue
|
||
|
||
# FFmpeg ending after the Icecast source disappeared is a normal
|
||
# end of the recording. Finalize it so normalization is performed.
|
||
if process is not None and process.poll() is not None:
|
||
if not active:
|
||
print(
|
||
f"[recorder] FFmpeg ended because the Icecast stream stopped: {current_filename}",
|
||
flush=True,
|
||
)
|
||
stop_recording()
|
||
source_seen_since = None
|
||
source_missing_since = None
|
||
time.sleep(POLL_INTERVAL)
|
||
continue
|
||
|
||
print(
|
||
"[recorder] FFmpeg exited unexpectedly while Icecast is active",
|
||
flush=True,
|
||
)
|
||
ffmpeg_process = None
|
||
recording_filename = None
|
||
recording_started = None
|
||
source_seen_since = now_ts
|
||
time.sleep(POLL_INTERVAL)
|
||
continue
|
||
|
||
if active:
|
||
source_missing_since = None
|
||
if process is None:
|
||
if source_seen_since is None:
|
||
source_seen_since = now_ts
|
||
elif now_ts - source_seen_since >= START_DELAY:
|
||
start_recording()
|
||
source_seen_since = None
|
||
else:
|
||
source_seen_since = None
|
||
else:
|
||
source_seen_since = None
|
||
if process is not None:
|
||
if source_missing_since is None:
|
||
source_missing_since = now_ts
|
||
elif now_ts - source_missing_since >= STOP_DELAY:
|
||
stop_recording()
|
||
source_missing_since = None
|
||
else:
|
||
source_missing_since = None
|
||
|
||
time.sleep(POLL_INTERVAL)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Web UI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
HTML = """
|
||
<!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;
|
||
}
|
||
|
||
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%;
|
||
}
|
||
|
||
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: 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">
|
||
|
||
{% 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 %}
|
||
|
||
|
||
<h2>Fertige Aufnahmen</h2>
|
||
|
||
{% if recordings %}
|
||
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Start</th>
|
||
<th>Datei</th>
|
||
<th>Größe</th>
|
||
<th>Dauer</th>
|
||
<th>Wiedergabe</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
|
||
<tbody>
|
||
|
||
{% for item in recordings %}
|
||
<tr>
|
||
|
||
<td>{{ item.datetime }}</td>
|
||
|
||
<td>{{ item.filename }}</td>
|
||
|
||
<td>{{ item.size }}</td>
|
||
|
||
<td>{{ item.duration }}</td>
|
||
|
||
<td>
|
||
<audio controls preload="none">
|
||
<source src="/recordings/{{ item.filename }}"
|
||
type="audio/mpeg">
|
||
</audio>
|
||
</td>
|
||
|
||
<td>
|
||
<a class="button"
|
||
href="/recordings/{{ 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">
|
||
Aktualisierung automatisch alle 10 Sekunden
|
||
</div>
|
||
|
||
<script>
|
||
async function updatePage() {
|
||
// Never rebuild the DOM while an audio player is playing or paused at
|
||
// a non-zero position. Replacing <audio> elements would reset playback.
|
||
const players = Array.from(document.querySelectorAll("audio"));
|
||
const audioActive = players.some(player =>
|
||
!player.paused || player.currentTime > 0
|
||
);
|
||
|
||
if (audioActive) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch("/api/status", {
|
||
cache: "no-store"
|
||
});
|
||
|
||
if (!response.ok) {
|
||
return;
|
||
}
|
||
|
||
const data = await response.text();
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(data, "text/html");
|
||
const newContent = doc.querySelector("#dynamic-content");
|
||
const currentContent = document.querySelector("#dynamic-content");
|
||
|
||
if (newContent && currentContent) {
|
||
currentContent.innerHTML = newContent.innerHTML;
|
||
}
|
||
} catch (error) {
|
||
console.warn("Status update failed:", error);
|
||
}
|
||
}
|
||
|
||
setInterval(updatePage, 10000);
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def render_index():
|
||
global ffmpeg_process
|
||
global recording_filename
|
||
global recording_started
|
||
global normalizing_filename
|
||
|
||
files = []
|
||
|
||
RECORDINGS_DIR.mkdir(
|
||
parents=True,
|
||
exist_ok=True,
|
||
)
|
||
|
||
for path in RECORDINGS_DIR.glob("*.mp3"):
|
||
try:
|
||
stat = path.stat()
|
||
|
||
# Skip the currently active file from the finished list.
|
||
if path.name == recording_filename:
|
||
continue
|
||
|
||
files.append({
|
||
"filename": path.name,
|
||
"datetime": format_datetime(stat.st_mtime),
|
||
"size": format_size(stat.st_size),
|
||
"duration": format_duration(
|
||
get_duration(path.name)
|
||
),
|
||
"mtime": stat.st_mtime,
|
||
})
|
||
|
||
except FileNotFoundError:
|
||
continue
|
||
|
||
files.sort(
|
||
key=lambda item: item["mtime"],
|
||
reverse=True,
|
||
)
|
||
|
||
with state_lock:
|
||
process = ffmpeg_process
|
||
current_filename = recording_filename
|
||
current_started = recording_started
|
||
status_error = not last_status_ok
|
||
|
||
recording = None
|
||
|
||
if process is not None and process.poll() is None:
|
||
current_size = "–"
|
||
|
||
if current_filename:
|
||
current_path = RECORDINGS_DIR / current_filename
|
||
|
||
try:
|
||
current_size = format_size(
|
||
current_path.stat().st_size
|
||
)
|
||
except FileNotFoundError:
|
||
pass
|
||
|
||
recording = {
|
||
"filename": current_filename,
|
||
"started": format_datetime(current_started),
|
||
"size": current_size,
|
||
}
|
||
|
||
return render_template_string(
|
||
HTML,
|
||
recordings=files,
|
||
recording=recording,
|
||
normalizing_filename=normalizing_filename,
|
||
status_error=status_error,
|
||
icecast_public_url=ICECAST_PUBLIC_URL,
|
||
)
|
||
|
||
|
||
@app.route("/")
|
||
def index():
|
||
return render_index()
|
||
|
||
|
||
@app.route("/api/status")
|
||
def api_status():
|
||
return render_index()
|
||
|
||
|
||
@app.route("/recordings/<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,
|
||
) |