From bfa7cc10467d03c484a93e60dc51c18ca4ac8d15 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Jul 2026 11:04:45 +0800 Subject: [PATCH 1/6] stream: show the source provider of the current track Co-Authored-By: Claude Opus 4.8 --- ingest/radieo/api.py | 8 +++++++- stream/index.html | 26 +++++++++++++++++++++++++- stream/radio.liq | 11 ++++++++--- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/ingest/radieo/api.py b/ingest/radieo/api.py index cacc9d6..f55b0c9 100644 --- a/ingest/radieo/api.py +++ b/ingest/radieo/api.py @@ -29,7 +29,13 @@ def annotate_uri(path: Path, track: Track) -> str: def esc(value: str) -> str: return value.replace("\\", "\\\\").replace('"', '\\"') - fields = [f'title="{esc(track.title)}"', f'artist="{esc(track.artist)}"'] + fields = [ + f'title="{esc(track.title)}"', + f'artist="{esc(track.artist)}"', + # Provider that produced the track (subsonic, ytdlp…), surfaced by the + # stream so the player can show a discreet source indicator. + f'origin="{esc(track.origin)}"', + ] # Web page the track was pulled from, so the player can link back to the # source. Only http(s) locators qualify (yt-dlp tracks); a Subsonic song id # is opaque and points at no public page. diff --git a/stream/index.html b/stream/index.html index fa9b90f..d6cbf68 100644 --- a/stream/index.html +++ b/stream/index.html @@ -37,6 +37,10 @@ color: #9b8cff; margin-bottom: 1.75rem; } .np-label { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; color: #7d768f; margin-bottom: .5rem; } + /* Provenance discrète du morceau : d'où vient la piste (OpenSubsonic, + YouTube…). Ton effacé, glissée à la suite de l'état « en cours ». */ + .provider { color: #6b6480; } + .provider::before { content: "·"; margin: 0 .35em; color: #4a4560; } .title { font-size: 1.55rem; font-weight: 650; line-height: 1.25; word-wrap: break-word; } .title a { color: inherit; text-decoration: none; transition: color .15s; } @@ -98,7 +102,7 @@
-
Préchargement
+
Préchargement
@@ -151,8 +155,20 @@ const titleEl = document.getElementById("title"); const artistEl = document.getElementById("artist"); const npLabel = document.getElementById("npLabel"); + const providerEl = document.getElementById("provider"); const player = document.getElementById("player"); + // Noms d'affichage des providers d'ingestion (champ `origin` du morceau). + // Inconnu → on réutilise tel quel, faute de mieux. + const PROVIDER_NAMES = { + subsonic: "OpenSubsonic", + ytdlp: "YouTube", + listenbrainz: "ListenBrainz", + // Filet de secours : morceau rejoué depuis le cache local (déjà diffusé), + // quand l'ingest n'a rien à proposer (démarrage, panne…). + cache: "le cache local", + }; + // 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. @@ -279,6 +295,14 @@ ? `${escapeHtml(label)}` : escapeHtml(label); artistEl.textContent = a; + // Provenance discrète : nom lisible du provider, masqué s'il est absent. + const origin = (m.origin || "").trim(); + if (origin) { + providerEl.textContent = "via " + (PROVIDER_NAMES[origin] || origin); + providerEl.hidden = false; + } else { + providerEl.hidden = true; + } document.title = t ? (a ? `${t} — ${a} · ${STATION_NAME}` : `${t} · ${STATION_NAME}`) : STATION_NAME; updateMediaSession(t, a); } catch (e) { /* keep last known values */ } diff --git a/stream/radio.liq b/stream/radio.liq index 40ac29b..da7cb3e 100644 --- a/stream/radio.liq +++ b/stream/radio.liq @@ -71,6 +71,11 @@ backup = playlist( mode="randomize", reload_mode="watch", mime_type="audio/x-mpegurl", check_next=audio_only, fallback_file ) +# Les morceaux du secours sont rejoués depuis le cache local (déjà diffusés) et +# n'ont pas d'annotation d'origine. On les étiquette explicitement pour que le +# player affiche « via le cache local » plutôt que rien quand on retombe sur ce +# filet (ingest injoignable, file vide pendant le préchargement…). +backup = metadata.map(fun(_) -> [("origin", "cache")], backup) # File de rejeu ponctuel : normalement vide (donc non prête, transparente). Le # endpoint /restart-track y pousse le morceau courant pour le rejouer depuis le @@ -198,8 +203,8 @@ radio.on_metadata( now_playing := m # `file` : nom de base du fichier à l'antenne, servant de jeton de # téléchargement (/download?file=…). Vide si la métadonnée manque. - entry = {title=m["title"], artist=m["artist"], url=m["url"], file=path.basename(m["filename"])} - head = list.hd(default={title="", artist="", url="", file=""}, history()) + entry = {title=m["title"], artist=m["artist"], url=m["url"], origin=m["origin"], file=path.basename(m["filename"])} + head = list.hd(default={title="", artist="", url="", origin="", file=""}, history()) is_dup = head.title == entry.title and head.artist == entry.artist if not is_dup and (entry.title != "" or entry.artist != "") then history := list.prefix(history_max, list.add(entry, history())) @@ -239,7 +244,7 @@ harbor.http.register( port=8000, method="GET", "/nowplaying", fun(_, resp) -> begin m = now_playing() - resp.json({title=m["title"], artist=m["artist"], url=m["url"]}) + resp.json({title=m["title"], artist=m["artist"], url=m["url"], origin=m["origin"]}) end ) From 126cb8f8acdcda030e9ce453aa8984f20caeaf52 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Jul 2026 11:07:09 +0800 Subject: [PATCH 2/6] stream: show "Connexion en cours" until prefetch is known Co-Authored-By: Claude Opus 4.8 --- stream/index.html | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/stream/index.html b/stream/index.html index d6cbf68..623bd4f 100644 --- a/stream/index.html +++ b/stream/index.html @@ -102,7 +102,7 @@
-
Préchargement
+
Connexion en cours
@@ -182,12 +182,14 @@ 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) { + const prefetch = Number.isFinite(Number(s.prefetch)) ? Number(s.prefetch) : -1; + if (prefetch >= 0 && ready >= prefetch) { bufferFull = true; npLabel.textContent = "en cours"; + } else if (prefetch >= 0) { + npLabel.textContent = `Préchargement ${ready}/${prefetch}`; } else { - npLabel.textContent = `Préchargement ${ready}/${prefetch || "…"}`; + npLabel.textContent = "Connexion en cours"; } } catch (e) { /* keep last known label */ } } From 62302ac21d631ff8904daf9c6dbed63f69992e6d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Jul 2026 11:10:37 +0800 Subject: [PATCH 3/6] 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 From d30f6871851984f1a43cbfbf54b8ec5fbbdba968 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Jul 2026 11:13:45 +0800 Subject: [PATCH 4/6] stream: switch queue and history with glossy tabs Co-Authored-By: Claude Opus 4.8 --- stream/index.html | 74 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/stream/index.html b/stream/index.html index 291e69f..0159d55 100644 --- a/stream/index.html +++ b/stream/index.html @@ -71,9 +71,39 @@ } .actions button:hover, .actions a:hover { background: rgba(155,140,255,.3); } .actions button:disabled { opacity: .5; cursor: default; } - .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; } + /* Onglets « glossy » pour basculer entre file d'attente et historique : + une barre pilule au fond translucide, l'onglet actif surligné par un + dégradé lumineux et un léger reflet en haut pour l'effet vernis. */ + .tabs { margin-top: 1.75rem; text-align: left; } + .tab-bar { + display: flex; gap: .35rem; padding: .3rem; + background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08); + border-radius: 12px; box-shadow: inset 0 1px 0 rgba(255,255,255,.06); + } + .tab-btn { + flex: 1; position: relative; overflow: hidden; cursor: pointer; + padding: .55rem .7rem; font-size: .7rem; font-weight: 600; + letter-spacing: .15em; text-transform: uppercase; color: #8b849c; + background: transparent; border: 1px solid transparent; border-radius: 9px; + transition: color .2s, background .2s, box-shadow .2s, border-color .2s; + } + .tab-btn:hover { color: #cfc9de; } + .tab-btn.active { + color: #f2f0f7; + background: linear-gradient(180deg, rgba(155,140,255,.45), rgba(123,110,220,.22)); + border-color: rgba(155,140,255,.5); + box-shadow: inset 0 1px 0 rgba(255,255,255,.35), + 0 4px 14px rgba(123,110,220,.35); + } + /* Reflet vitreux : une bande claire sur la moitié haute de l'onglet actif. */ + .tab-btn.active::before { + content: ""; position: absolute; inset: 0 0 50%; + background: linear-gradient(180deg, rgba(255,255,255,.28), transparent); + pointer-events: none; + } + .tab-panel { margin-top: 1rem; text-align: left; } + .tab-panel[hidden] { display: none; } + .history, .queue { text-align: left; } .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 { @@ -120,14 +150,18 @@ -
      -

      File d'attente

      -
        -
        -
        -

        Historique

        -
          -
          +
          +
          + + +
          +
          +
            +
            + +