Suchfunktion eingebaut
This commit is contained in:
+126
-34
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import requests
|
||||
from flask import Flask, render_template_string, send_from_directory, abort, Response, request
|
||||
from flask import Flask, render_template_string, send_from_directory, abort, Response, request, stream_with_context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -70,6 +70,7 @@ NORMALIZE_TRUE_PEAK = os.environ.get("NORMALIZE_TRUE_PEAK", "-1.5")
|
||||
NORMALIZE_LRA = os.environ.get("NORMALIZE_LRA", "11")
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application state
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -90,6 +91,9 @@ last_status_ok = False
|
||||
last_status_check = None
|
||||
last_status_error = None
|
||||
|
||||
SSE_CLIENTS = set()
|
||||
SSE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -294,6 +298,43 @@ def query_icecast():
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -333,6 +374,8 @@ def start_recording():
|
||||
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."""
|
||||
@@ -435,6 +478,8 @@ def stop_recording():
|
||||
finally:
|
||||
normalizing_filename = None
|
||||
|
||||
broadcast_event("recording_stopped", filename)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Monitoring loop
|
||||
@@ -732,6 +777,7 @@ a.button:hover {
|
||||
|
||||
<div id="dynamic-content">
|
||||
|
||||
<div id="live-section">
|
||||
{% if normalizing_filename %}
|
||||
<div class="status recording">
|
||||
● AUFNAHME WIRD NORMALISIERT
|
||||
@@ -778,7 +824,7 @@ a.button:hover {
|
||||
● keine laufende Aufnahme
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<h2>Fertige Aufnahmen</h2>
|
||||
|
||||
@@ -844,46 +890,25 @@ a.button:hover {
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Aktualisierung automatisch alle 10 Sekunden
|
||||
Live-Status wird bei Start und Ende einer Aufnahme automatisch aktualisiert.
|
||||
</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;
|
||||
}
|
||||
|
||||
async function updateLiveSection() {
|
||||
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;
|
||||
}
|
||||
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("Status update failed:", error);
|
||||
console.warn("Live-Status update failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(updatePage, 10000);
|
||||
const events = new EventSource("/api/events");
|
||||
events.onmessage = () => updateLiveSection();
|
||||
events.onerror = () => { /* EventSource reconnects automatically. */ };
|
||||
</script>
|
||||
|
||||
</body>
|
||||
@@ -962,6 +987,73 @@ 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()
|
||||
|
||||
Reference in New Issue
Block a user