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);