From 1648030eba1eb0d5e56ddd152e418a6ff984269e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 3 Jul 2026 12:22:53 +0800 Subject: [PATCH] stream: surface ingest prefetch progress in the player Co-Authored-By: Claude Opus 4.8 --- ingest/radieo/api.py | 14 ++++++++++++++ ingest/radieo/queue.py | 7 +++++++ stream/index.html | 29 +++++++++++++++++++++++++++-- stream/radio.liq | 18 ++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/ingest/radieo/api.py b/ingest/radieo/api.py index 4830701..cacc9d6 100644 --- a/ingest/radieo/api.py +++ b/ingest/radieo/api.py @@ -5,9 +5,12 @@ Endpoints: is ready (Liquidsoap then falls back to /fallback.m3u). GET /fallback.m3u -> playlist of already-aired files, the stream's safety 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" """ +import json import logging from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -50,6 +53,8 @@ class _Handler(BaseHTTPRequestHandler): self._serve_next() elif self.path == "/fallback.m3u": self._serve_fallback() + elif self.path == "/status": + self._serve_status() elif self.path == "/healthz": self._text(200, "ok\n") else: @@ -73,6 +78,15 @@ class _Handler(BaseHTTPRequestHandler): lines.append(p) 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"): data = body.encode("utf-8") self.send_response(code) diff --git a/ingest/radieo/queue.py b/ingest/radieo/queue.py index cb83d0a..6b75f29 100644 --- a/ingest/radieo/queue.py +++ b/ingest/radieo/queue.py @@ -74,6 +74,13 @@ class TrackQueue: with self._lock: 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 ---------------------------------------------------------- def pop_next(self) -> tuple[Path, Track] | None: diff --git a/stream/index.html b/stream/index.html index 8c8d6bb..ee1ac6f 100644 --- a/stream/index.html +++ b/stream/index.html @@ -83,7 +83,7 @@
-
en cours
+
Préchargement
@@ -108,8 +108,32 @@ const titleEl = document.getElementById("title"); const artistEl = document.getElementById("artist"); + const npLabel = document.getElementById("npLabel"); 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 // (re)connecter au direct au lieu de rejouer un buffer périmé. const liveUrl = () => "/radio.mp3?t=" + Date.now(); @@ -239,7 +263,8 @@ poll(); pollHistory(); - setInterval(() => { poll(); pollHistory(); }, 5000); + pollStatus(); + setInterval(() => { poll(); pollHistory(); pollStatus(); }, 5000); diff --git a/stream/radio.liq b/stream/radio.liq index f567eff..5efec84 100644 --- a/stream/radio.liq +++ b/stream/radio.liq @@ -169,6 +169,24 @@ harbor.http.register( 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 # 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).