stream: surface ingest prefetch progress in the player
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c12e522fee
commit
1648030eba
4 changed files with 66 additions and 2 deletions
|
|
@ -5,9 +5,12 @@ Endpoints:
|
||||||
is ready (Liquidsoap then falls back to /fallback.m3u).
|
is ready (Liquidsoap then falls back to /fallback.m3u).
|
||||||
GET /fallback.m3u -> playlist of already-aired files, the stream's safety
|
GET /fallback.m3u -> playlist of already-aired files, the stream's safety
|
||||||
net; empty (→ silence) until something has played.
|
net; empty (→ silence) until something has played.
|
||||||
|
GET /status -> JSON prefetch state {ready, prefetch}, surfaced to the
|
||||||
|
player (proxied by the stream) so it can show buffering.
|
||||||
GET /healthz -> "ok"
|
GET /healthz -> "ok"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -50,6 +53,8 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
self._serve_next()
|
self._serve_next()
|
||||||
elif self.path == "/fallback.m3u":
|
elif self.path == "/fallback.m3u":
|
||||||
self._serve_fallback()
|
self._serve_fallback()
|
||||||
|
elif self.path == "/status":
|
||||||
|
self._serve_status()
|
||||||
elif self.path == "/healthz":
|
elif self.path == "/healthz":
|
||||||
self._text(200, "ok\n")
|
self._text(200, "ok\n")
|
||||||
else:
|
else:
|
||||||
|
|
@ -73,6 +78,15 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
lines.append(p)
|
lines.append(p)
|
||||||
self._text(200, "\n".join(lines) + "\n", "audio/x-mpegurl; charset=utf-8")
|
self._text(200, "\n".join(lines) + "\n", "audio/x-mpegurl; charset=utf-8")
|
||||||
|
|
||||||
|
def _serve_status(self):
|
||||||
|
# Prefetch progress: how many tracks are buffered vs. the target. The
|
||||||
|
# stream's initial buffer is "full" once ready reaches PREFETCH.
|
||||||
|
body = json.dumps({
|
||||||
|
"ready": self.server.queue.ready_count(),
|
||||||
|
"prefetch": config.PREFETCH,
|
||||||
|
})
|
||||||
|
self._text(200, body + "\n", "application/json; charset=utf-8")
|
||||||
|
|
||||||
def _text(self, code: int, body: str, ctype: str = "text/plain; charset=utf-8"):
|
def _text(self, code: int, body: str, ctype: str = "text/plain; charset=utf-8"):
|
||||||
data = body.encode("utf-8")
|
data = body.encode("utf-8")
|
||||||
self.send_response(code)
|
self.send_response(code)
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,13 @@ class TrackQueue:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._ready.append((path, track))
|
self._ready.append((path, track))
|
||||||
|
|
||||||
|
# --- introspection ----------------------------------------------------
|
||||||
|
|
||||||
|
def ready_count(self) -> int:
|
||||||
|
"""Number of tracks currently downloaded and ready to play."""
|
||||||
|
with self._lock:
|
||||||
|
return len(self._ready)
|
||||||
|
|
||||||
# --- serving ----------------------------------------------------------
|
# --- serving ----------------------------------------------------------
|
||||||
|
|
||||||
def pop_next(self) -> tuple[Path, Track] | None:
|
def pop_next(self) -> tuple[Path, Track] | None:
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
<body>
|
<body>
|
||||||
<main class="card">
|
<main class="card">
|
||||||
<div class="logo" id="stationName">◈</div>
|
<div class="logo" id="stationName">◈</div>
|
||||||
<div class="np-label"><span class="dot"></span>en cours</div>
|
<div class="np-label"><span class="dot"></span><span id="npLabel">Préchargement</span></div>
|
||||||
<div class="title" id="title">—</div>
|
<div class="title" id="title">—</div>
|
||||||
<div class="artist" id="artist"></div>
|
<div class="artist" id="artist"></div>
|
||||||
<audio id="player" controls autoplay preload="none"></audio>
|
<audio id="player" controls autoplay preload="none"></audio>
|
||||||
|
|
@ -108,8 +108,32 @@
|
||||||
|
|
||||||
const titleEl = document.getElementById("title");
|
const titleEl = document.getElementById("title");
|
||||||
const artistEl = document.getElementById("artist");
|
const artistEl = document.getElementById("artist");
|
||||||
|
const npLabel = document.getElementById("npLabel");
|
||||||
const player = document.getElementById("player");
|
const player = document.getElementById("player");
|
||||||
|
|
||||||
|
// Tant que le buffer de préchargement (PREFETCH côté ingest) n'est pas
|
||||||
|
// rempli, on affiche « Préchargement N/M » plutôt que « en cours ». L'info
|
||||||
|
// vient du daemon d'ingestion, relayée par le stream via /ingest/status.
|
||||||
|
// Dès que le buffer est plein (ready >= prefetch), on bascule définitivement
|
||||||
|
// sur « en cours » : ensuite le buffer se vide et se remplit en continu, ce
|
||||||
|
// n'est plus un état de démarrage à signaler.
|
||||||
|
let bufferFull = false;
|
||||||
|
async function pollStatus() {
|
||||||
|
if (bufferFull) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch("/ingest/status", { cache: "no-store" });
|
||||||
|
const s = await r.json();
|
||||||
|
const ready = Number(s.ready) || 0;
|
||||||
|
const prefetch = Number(s.prefetch) || 0;
|
||||||
|
if (prefetch > 0 && ready >= prefetch) {
|
||||||
|
bufferFull = true;
|
||||||
|
npLabel.textContent = "en cours";
|
||||||
|
} else {
|
||||||
|
npLabel.textContent = `Préchargement ${ready}/${prefetch || "…"}`;
|
||||||
|
}
|
||||||
|
} catch (e) { /* keep last known label */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Flux « live » : un paramètre anti-cache force le navigateur à se
|
// Flux « live » : un paramètre anti-cache force le navigateur à se
|
||||||
// (re)connecter au direct au lieu de rejouer un buffer périmé.
|
// (re)connecter au direct au lieu de rejouer un buffer périmé.
|
||||||
const liveUrl = () => "/radio.mp3?t=" + Date.now();
|
const liveUrl = () => "/radio.mp3?t=" + Date.now();
|
||||||
|
|
@ -239,7 +263,8 @@
|
||||||
|
|
||||||
poll();
|
poll();
|
||||||
pollHistory();
|
pollHistory();
|
||||||
setInterval(() => { poll(); pollHistory(); }, 5000);
|
pollStatus();
|
||||||
|
setInterval(() => { poll(); pollHistory(); pollStatus(); }, 5000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,24 @@ harbor.http.register(
|
||||||
fun(_, resp) -> resp.json(history())
|
fun(_, resp) -> resp.json(history())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# État du préchargement, relayé depuis le daemon d'ingestion (reverse proxy) :
|
||||||
|
# le player n'a pas accès direct au réseau interne, on lui expose donc l'info
|
||||||
|
# {ready, prefetch} via le même harbor que le flux. Si le daemon est injoignable
|
||||||
|
# on renvoie un objet neutre plutôt qu'une erreur, pour ne pas casser le player.
|
||||||
|
ingest_status_url = "http://ingest:8080/status"
|
||||||
|
harbor.http.register(
|
||||||
|
port=8000, method="GET", "/ingest/status",
|
||||||
|
fun(_, resp) -> begin
|
||||||
|
resp.content_type("application/json; charset=utf-8")
|
||||||
|
body = http.get(ingest_status_url, timeout=5.0)
|
||||||
|
if body.status_code == 200 then
|
||||||
|
resp.data(string.trim(body) ^ "\n")
|
||||||
|
else
|
||||||
|
resp.data("{}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
# Passer au morceau suivant : on saute le morceau en cours sur la source
|
# Passer au morceau suivant : on saute le morceau en cours sur la source
|
||||||
# diffusée. request.dynamic a déjà préchargé le suivant, donc l'enchaînement
|
# diffusée. request.dynamic a déjà préchargé le suivant, donc l'enchaînement
|
||||||
# est immédiat (le prochain /next est demandé au daemon dans la foulée).
|
# est immédiat (le prochain /next est demandé au daemon dans la foulée).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue