From 62302ac21d631ff8904daf9c6dbed63f69992e6d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Jul 2026 11:10:37 +0800 Subject: [PATCH] stream: show the queue of upcoming tracks (/queue) Co-Authored-By: Claude Opus 4.8 --- ingest/radieo/api.py | 9 +++++++ ingest/radieo/queue.py | 21 +++++++++++++++ stream/index.html | 61 ++++++++++++++++++++++++++++++++++-------- stream/radio.liq | 18 +++++++++++++ 4 files changed, 98 insertions(+), 11 deletions(-) diff --git a/ingest/radieo/api.py b/ingest/radieo/api.py index f55b0c9..e1a3bf2 100644 --- a/ingest/radieo/api.py +++ b/ingest/radieo/api.py @@ -7,6 +7,8 @@ Endpoints: 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 /queue -> JSON list of the upcoming (prefetched) tracks, oldest + first, surfaced to the player (proxied by the stream). GET /healthz -> "ok" """ @@ -61,6 +63,8 @@ class _Handler(BaseHTTPRequestHandler): self._serve_fallback() elif self.path == "/status": self._serve_status() + elif self.path == "/queue": + self._serve_queue() elif self.path == "/healthz": self._text(200, "ok\n") else: @@ -93,6 +97,11 @@ class _Handler(BaseHTTPRequestHandler): }) self._text(200, body + "\n", "application/json; charset=utf-8") + def _serve_queue(self): + # Upcoming prefetched tracks, next first. A peek, nothing consumed. + body = json.dumps(self.server.queue.snapshot()) + 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 6b75f29..c6d242c 100644 --- a/ingest/radieo/queue.py +++ b/ingest/radieo/queue.py @@ -81,6 +81,27 @@ class TrackQueue: with self._lock: return len(self._ready) + def snapshot(self) -> list[dict]: + """Display metadata of the upcoming tracks, oldest (next) first. + + A peek at the prefetch buffer for the player's "up next" view; it does + not consume anything. Mirrors the fields exposed for the current track + (see ``annotate_uri``): a source ``url`` only for http(s) locators. + """ + with self._lock: + ready = list(self._ready) + items = [] + for _path, track in ready: + entry = { + "title": track.title, + "artist": track.artist, + "origin": track.origin, + } + if track.locator.startswith(("http://", "https://")): + entry["url"] = track.locator + items.append(entry) + return items + # --- serving ---------------------------------------------------------- def pop_next(self) -> tuple[Path, Track] | None: diff --git a/stream/index.html b/stream/index.html index 623bd4f..291e69f 100644 --- a/stream/index.html +++ b/stream/index.html @@ -71,11 +71,11 @@ } .actions button:hover, .actions a:hover { background: rgba(155,140,255,.3); } .actions button:disabled { opacity: .5; cursor: default; } - .history { margin-top: 1.75rem; text-align: left; } - .history h2 { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; + .history, .queue { margin-top: 1.75rem; text-align: left; } + .history h2, .queue h2 { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; color: #7d768f; margin: 0 0 .4rem; font-weight: 600; } - .history ul { list-style: none; margin: 0; padding: 0; } - .history li { border-top: 1px solid rgba(255,255,255,.06); } + .history ul, .queue ul { list-style: none; margin: 0; padding: 0; } + .history li, .queue li { border-top: 1px solid rgba(255,255,255,.06); } .history .h-row { display: flex; align-items: center; gap: .6rem; padding: .45rem 0; } @@ -83,11 +83,17 @@ .history .h-act { color: #7d768f; font-size: .95rem; text-decoration: none; transition: color .15s; } .history .h-act:hover { color: #9b8cff; } - .history .h-title { color: #e8e4f2; font-size: .92rem; } - .history .h-title a { color: inherit; text-decoration: none; transition: color .15s; } - .history .h-title a:hover { color: #9b8cff; text-decoration: underline; } - .history .h-artist { color: #8b849c; font-size: .8rem; margin-top: .1rem; } - .history .empty { color: #6b6480; font-size: .85rem; padding: .45rem 0; } + .history .h-title, .queue .h-title { color: #e8e4f2; font-size: .92rem; } + .history .h-title a, .queue .h-title a { color: inherit; text-decoration: none; transition: color .15s; } + .history .h-title a:hover, .queue .h-title a:hover { color: #9b8cff; text-decoration: underline; } + .history .h-artist, .queue .h-artist { color: #8b849c; font-size: .8rem; margin-top: .1rem; } + .history .empty, .queue .empty { color: #6b6480; font-size: .85rem; padding: .45rem 0; } + /* File d'attente : rang discret devant chaque titre à venir. */ + .queue li { padding: .45rem 0; } + .queue .q-item { display: flex; align-items: baseline; gap: .6rem; } + .queue .q-num { color: #6b6480; font-size: .8rem; font-variant-numeric: tabular-nums; + min-width: 1.2em; text-align: right; } + .queue .q-meta { flex: 1; min-width: 0; } .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #4ade80; margin-right: .4rem; vertical-align: middle; box-shadow: 0 0 0 0 rgba(74,222,128,.6); animation: pulse 2s infinite; } @@ -114,6 +120,10 @@ +
+

File d'attente

+
    +

    Historique

      @@ -344,6 +354,34 @@ }).join(""); } catch (e) { /* keep last known values */ } } + + // File d'attente : les prochains morceaux déjà préchargés par le daemon + // d'ingestion, relayés par le stream via /queue (le plus proche en tête). + // La liste est courte (bornée par PREFETCH) et peut être vide au démarrage. + const queueList = document.getElementById("queueList"); + async function pollQueue() { + try { + const r = await fetch("/queue", { cache: "no-store" }); + const items = await r.json(); + const next = Array.isArray(items) ? items : []; + if (next.length === 0) { + queueList.innerHTML = '
    • '; + return; + } + queueList.innerHTML = next.map((m, i) => { + const t = escapeHtml((m.title || "").trim() || "—"); + const a = (m.artist || "").trim(); + const artist = a ? `
      ${escapeHtml(a)}
      ` : ""; + // Titre cliquable vers la page d'origine (yt-dlp) quand elle existe. + const u = (m.url || "").trim(); + const titleHtml = u + ? `${t}` + : t; + const meta = `
      ${titleHtml}
      ${artist}
      `; + return `
    • ${i + 1}${meta}
    • `; + }).join(""); + } catch (e) { /* keep last known values */ } + } // Lien nu du flux, à ouvrir dans un lecteur externe (VLC…). const shareUrl = location.origin + "/radio.mp3"; const urlEl = document.getElementById("streamUrl"); @@ -367,13 +405,14 @@ skipBtn.disabled = true; try { await fetch("/skip", { method: "POST" }); } catch (e) { /* ignore */ } // Laisser le temps à la bascule, puis rafraîchir l'affichage. - setTimeout(() => { skipBtn.disabled = false; poll(); pollHistory(); }, 900); + setTimeout(() => { skipBtn.disabled = false; poll(); pollHistory(); pollQueue(); }, 900); }); poll(); pollHistory(); + pollQueue(); pollStatus(); - setInterval(() => { poll(); pollHistory(); pollStatus(); }, 5000); + setInterval(() => { poll(); pollHistory(); pollQueue(); pollStatus(); }, 5000); diff --git a/stream/radio.liq b/stream/radio.liq index da7cb3e..9177255 100644 --- a/stream/radio.liq +++ b/stream/radio.liq @@ -254,6 +254,24 @@ harbor.http.register( fun(_, resp) -> resp.json(history()) ) +# File d'attente des prochains morceaux, relayée depuis le daemon d'ingestion +# (le player n'a pas d'accès direct au réseau interne). Comme /ingest/status, on +# renvoie une valeur neutre — ici une liste vide — si le daemon est injoignable, +# pour ne pas casser le player. +ingest_queue_url = "http://ingest:8080/queue" +harbor.http.register( + port=8000, method="GET", "/queue", + fun(_, resp) -> begin + resp.content_type("application/json; charset=utf-8") + body = http.get(ingest_queue_url, timeout=5.0) + if body.status_code == 200 then + resp.data(string.trim(body) ^ "\n") + else + resp.data("[]") + end + end +) + # É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