stream: let listeners queue a yt-dlp URL on request
All checks were successful
continuous-integration/drone/push Build is passing

Add an input in the queue tab to enqueue a yt-dlp URL: a single track, or
a whole playlist/album. Requests are a priority lane in the ingest queue —
pop_next serves them before the auto radio, so the next /next plays the
request without cutting the current track. They download lazily (a few
ahead), so a large playlist queues instantly and bypasses anti-repeat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-07-04 12:18:06 +08:00
commit 493e55ed18
5 changed files with 225 additions and 22 deletions

View file

@ -157,6 +157,25 @@
.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; }
/* Ajout à la file : un input d'URL + bouton, calqués sur le bloc « share ».
Le message de retour s'affiche discrètement sous le formulaire, en rouge
tamisé quand c'est une erreur. */
.enqueue { display: flex; gap: .5rem; margin-bottom: .75rem; }
.enqueue input {
flex: 1; min-width: 0; padding: .55rem .7rem; font-size: .85rem;
color: #cfc9de; background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.1); border-radius: 10px;
}
.enqueue button {
padding: .55rem .9rem; font-size: .8rem; font-weight: 600; cursor: pointer;
color: #f2f0f7; background: rgba(155,140,255,.18);
border: 1px solid rgba(155,140,255,.35); border-radius: 10px;
transition: background .15s;
}
.enqueue button:hover { background: rgba(155,140,255,.3); }
.enqueue button:disabled { opacity: .5; cursor: default; }
.enqueue-msg { font-size: .8rem; color: #8ad9a0; margin-bottom: .5rem; }
.enqueue-msg.err { color: #e08b9b; }
.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; }
@ -193,6 +212,12 @@
<ul id="historyList"></ul>
</section>
<section class="tab-panel queue" id="panelQueue" role="tabpanel" aria-labelledby="tabQueue" hidden>
<form class="enqueue" id="enqueueForm">
<input id="enqueueUrl" type="url" inputmode="url"
placeholder="URL yt-dlp (piste, playlist ou album)…" required>
<button type="submit" id="enqueueBtn">Ajouter</button>
</form>
<div class="enqueue-msg" id="enqueueMsg" hidden></div>
<ul id="queueList"></ul>
</section>
</div>
@ -245,6 +270,8 @@
// 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",
// Morceau mis en file par un auditeur via l'input « Ajouter à la file ».
request: "une demande",
};
// Tant que le buffer de préchargement (PREFETCH côté ingest) n'est pas
@ -450,6 +477,47 @@
}).join("");
} catch (e) { /* keep last known values */ }
}
// Ajout à la file : on POST l'URL au stream, qui la relaie à l'ingest. Ce
// dernier résout l'URL (piste seule, ou playlist/album entier) et la place
// en file prioritaire — le prochain morceau diffusé sera la demande. On
// affiche un retour bref puis on rafraîchit la file. La résolution d'une
// grosse playlist peut prendre quelques secondes.
const enqueueForm = document.getElementById("enqueueForm");
const enqueueUrl = document.getElementById("enqueueUrl");
const enqueueBtn = document.getElementById("enqueueBtn");
const enqueueMsg = document.getElementById("enqueueMsg");
function showEnqueueMsg(text, ok) {
enqueueMsg.textContent = text;
enqueueMsg.classList.toggle("err", !ok);
enqueueMsg.hidden = false;
}
enqueueForm.addEventListener("submit", async (e) => {
e.preventDefault();
const link = enqueueUrl.value.trim();
if (!link) return;
const prev = enqueueBtn.textContent;
enqueueBtn.disabled = true;
enqueueBtn.textContent = "…";
try {
const r = await fetch("/enqueue?url=" + encodeURIComponent(link), { method: "POST" });
if (r.ok) {
const d = await r.json().catch(() => ({}));
const n = Number(d.queued) || 0;
showEnqueueMsg(n > 1 ? `${n} titres ajoutés à la file` : "1 titre ajouté à la file", true);
enqueueUrl.value = "";
pollQueue();
} else {
showEnqueueMsg("Impossible de résoudre cette URL", false);
}
} catch (err) {
showEnqueueMsg("Erreur réseau", false);
} finally {
enqueueBtn.disabled = false;
enqueueBtn.textContent = prev;
}
});
// Lien nu du flux, à ouvrir dans un lecteur externe (VLC…).
const shareUrl = location.origin + "/radio.mp3";
const urlEl = document.getElementById("streamUrl");

View file

@ -290,6 +290,32 @@ harbor.http.register(
end
)
# Mettre une URL yt-dlp en file d'attente (piste seule, ou playlist/album
# entier). Le player n'a pas accès au réseau interne : on relaie la demande vers
# l'ingest, qui résout l'URL et la place en file prioritaire (le prochain /next
# la servira). On renvoie tel quel son code et son corps JSON ({queued: N} ou
# une erreur). Timeout large : résoudre une grosse playlist peut prendre du
# temps. NB : la variable locale s'appelle `link`, pas `url`, pour ne pas
# masquer le module `url` (url.encode).
ingest_enqueue_url = "http://ingest:8080/enqueue"
harbor.http.register(
port=8000, method="POST", "/enqueue",
fun(req, resp) -> begin
link = list.assoc(default="", "url", req.query)
if link == "" then
resp.status_code(400)
resp.data("missing url")
else
body = http.post(
data="", timeout=60.0, "#{ingest_enqueue_url}?url=#{url.encode(link)}"
)
resp.status_code(body.status_code)
resp.content_type("application/json; charset=utf-8")
resp.data(string.trim(body) ^ "\n")
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).