stream: show the queue of upcoming tracks (/queue)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
126cb8f8ac
commit
62302ac21d
4 changed files with 98 additions and 11 deletions
|
|
@ -7,6 +7,8 @@ Endpoints:
|
||||||
net; empty (→ silence) until something has played.
|
net; empty (→ silence) until something has played.
|
||||||
GET /status -> JSON prefetch state {ready, prefetch}, surfaced to the
|
GET /status -> JSON prefetch state {ready, prefetch}, surfaced to the
|
||||||
player (proxied by the stream) so it can show buffering.
|
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"
|
GET /healthz -> "ok"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -61,6 +63,8 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
self._serve_fallback()
|
self._serve_fallback()
|
||||||
elif self.path == "/status":
|
elif self.path == "/status":
|
||||||
self._serve_status()
|
self._serve_status()
|
||||||
|
elif self.path == "/queue":
|
||||||
|
self._serve_queue()
|
||||||
elif self.path == "/healthz":
|
elif self.path == "/healthz":
|
||||||
self._text(200, "ok\n")
|
self._text(200, "ok\n")
|
||||||
else:
|
else:
|
||||||
|
|
@ -93,6 +97,11 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
})
|
})
|
||||||
self._text(200, body + "\n", "application/json; charset=utf-8")
|
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"):
|
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)
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,27 @@ class TrackQueue:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return len(self._ready)
|
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 ----------------------------------------------------------
|
# --- serving ----------------------------------------------------------
|
||||||
|
|
||||||
def pop_next(self) -> tuple[Path, Track] | None:
|
def pop_next(self) -> tuple[Path, Track] | None:
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,11 @@
|
||||||
}
|
}
|
||||||
.actions button:hover, .actions a:hover { background: rgba(155,140,255,.3); }
|
.actions button:hover, .actions a:hover { background: rgba(155,140,255,.3); }
|
||||||
.actions button:disabled { opacity: .5; cursor: default; }
|
.actions button:disabled { opacity: .5; cursor: default; }
|
||||||
.history { margin-top: 1.75rem; text-align: left; }
|
.history, .queue { margin-top: 1.75rem; text-align: left; }
|
||||||
.history h2 { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase;
|
.history h2, .queue h2 { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase;
|
||||||
color: #7d768f; margin: 0 0 .4rem; font-weight: 600; }
|
color: #7d768f; margin: 0 0 .4rem; font-weight: 600; }
|
||||||
.history ul { list-style: none; margin: 0; padding: 0; }
|
.history ul, .queue ul { list-style: none; margin: 0; padding: 0; }
|
||||||
.history li { border-top: 1px solid rgba(255,255,255,.06); }
|
.history li, .queue li { border-top: 1px solid rgba(255,255,255,.06); }
|
||||||
.history .h-row {
|
.history .h-row {
|
||||||
display: flex; align-items: center; gap: .6rem; padding: .45rem 0;
|
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;
|
.history .h-act { color: #7d768f; font-size: .95rem; text-decoration: none;
|
||||||
transition: color .15s; }
|
transition: color .15s; }
|
||||||
.history .h-act:hover { color: #9b8cff; }
|
.history .h-act:hover { color: #9b8cff; }
|
||||||
.history .h-title { color: #e8e4f2; font-size: .92rem; }
|
.history .h-title, .queue .h-title { color: #e8e4f2; font-size: .92rem; }
|
||||||
.history .h-title a { color: inherit; text-decoration: none; transition: color .15s; }
|
.history .h-title a, .queue .h-title a { color: inherit; text-decoration: none; transition: color .15s; }
|
||||||
.history .h-title a:hover { color: #9b8cff; text-decoration: underline; }
|
.history .h-title a:hover, .queue .h-title a:hover { color: #9b8cff; text-decoration: underline; }
|
||||||
.history .h-artist { color: #8b849c; font-size: .8rem; margin-top: .1rem; }
|
.history .h-artist, .queue .h-artist { color: #8b849c; font-size: .8rem; margin-top: .1rem; }
|
||||||
.history .empty { color: #6b6480; font-size: .85rem; padding: .45rem 0; }
|
.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%;
|
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||||
background: #4ade80; margin-right: .4rem; vertical-align: middle;
|
background: #4ade80; margin-right: .4rem; vertical-align: middle;
|
||||||
box-shadow: 0 0 0 0 rgba(74,222,128,.6); animation: pulse 2s infinite; }
|
box-shadow: 0 0 0 0 rgba(74,222,128,.6); animation: pulse 2s infinite; }
|
||||||
|
|
@ -114,6 +120,10 @@
|
||||||
<input id="streamUrl" type="text" readonly>
|
<input id="streamUrl" type="text" readonly>
|
||||||
<button id="copyBtn" type="button">Copier</button>
|
<button id="copyBtn" type="button">Copier</button>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="queue">
|
||||||
|
<h2>File d'attente</h2>
|
||||||
|
<ul id="queueList"></ul>
|
||||||
|
</section>
|
||||||
<section class="history">
|
<section class="history">
|
||||||
<h2>Historique</h2>
|
<h2>Historique</h2>
|
||||||
<ul id="historyList"></ul>
|
<ul id="historyList"></ul>
|
||||||
|
|
@ -344,6 +354,34 @@
|
||||||
}).join("");
|
}).join("");
|
||||||
} catch (e) { /* keep last known values */ }
|
} 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 = '<li class="empty">—</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queueList.innerHTML = next.map((m, i) => {
|
||||||
|
const t = escapeHtml((m.title || "").trim() || "—");
|
||||||
|
const a = (m.artist || "").trim();
|
||||||
|
const artist = a ? `<div class="h-artist">${escapeHtml(a)}</div>` : "";
|
||||||
|
// Titre cliquable vers la page d'origine (yt-dlp) quand elle existe.
|
||||||
|
const u = (m.url || "").trim();
|
||||||
|
const titleHtml = u
|
||||||
|
? `<a href="${escapeHtml(u)}" target="_blank" rel="noopener">${t}</a>`
|
||||||
|
: t;
|
||||||
|
const meta = `<div class="q-meta"><div class="h-title">${titleHtml}</div>${artist}</div>`;
|
||||||
|
return `<li><div class="q-item"><span class="q-num">${i + 1}</span>${meta}</div></li>`;
|
||||||
|
}).join("");
|
||||||
|
} catch (e) { /* keep last known values */ }
|
||||||
|
}
|
||||||
// Lien nu du flux, à ouvrir dans un lecteur externe (VLC…).
|
// Lien nu du flux, à ouvrir dans un lecteur externe (VLC…).
|
||||||
const shareUrl = location.origin + "/radio.mp3";
|
const shareUrl = location.origin + "/radio.mp3";
|
||||||
const urlEl = document.getElementById("streamUrl");
|
const urlEl = document.getElementById("streamUrl");
|
||||||
|
|
@ -367,13 +405,14 @@
|
||||||
skipBtn.disabled = true;
|
skipBtn.disabled = true;
|
||||||
try { await fetch("/skip", { method: "POST" }); } catch (e) { /* ignore */ }
|
try { await fetch("/skip", { method: "POST" }); } catch (e) { /* ignore */ }
|
||||||
// Laisser le temps à la bascule, puis rafraîchir l'affichage.
|
// 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();
|
poll();
|
||||||
pollHistory();
|
pollHistory();
|
||||||
|
pollQueue();
|
||||||
pollStatus();
|
pollStatus();
|
||||||
setInterval(() => { poll(); pollHistory(); pollStatus(); }, 5000);
|
setInterval(() => { poll(); pollHistory(); pollQueue(); pollStatus(); }, 5000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,24 @@ harbor.http.register(
|
||||||
fun(_, resp) -> resp.json(history())
|
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) :
|
# É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
|
# 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
|
# {ready, prefetch} via le même harbor que le flux. Si le daemon est injoignable
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue