stream: let listeners remove a track from the queue
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
493e55ed18
commit
976f009297
5 changed files with 125 additions and 2 deletions
|
|
@ -12,6 +12,9 @@ Endpoints:
|
||||||
POST /enqueue?url= -> resolve a yt-dlp URL (single track or whole playlist/
|
POST /enqueue?url= -> resolve a yt-dlp URL (single track or whole playlist/
|
||||||
album) and queue it as priority requests; returns
|
album) and queue it as priority requests; returns
|
||||||
{queued: N}. Proxied by the stream.
|
{queued: N}. Proxied by the stream.
|
||||||
|
POST /dequeue?id= -> drop one upcoming track (the opaque id from /queue)
|
||||||
|
from the queue; returns {removed: bool}. Proxied by
|
||||||
|
the stream.
|
||||||
GET /healthz -> "ok"
|
GET /healthz -> "ok"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -87,6 +90,8 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
self._serve_share(parse_qs(parsed.query))
|
self._serve_share(parse_qs(parsed.query))
|
||||||
elif parsed.path == "/enqueue":
|
elif parsed.path == "/enqueue":
|
||||||
self._serve_enqueue(parse_qs(parsed.query))
|
self._serve_enqueue(parse_qs(parsed.query))
|
||||||
|
elif parsed.path == "/dequeue":
|
||||||
|
self._serve_dequeue(parse_qs(parsed.query))
|
||||||
else:
|
else:
|
||||||
self._text(404, "not found\n")
|
self._text(404, "not found\n")
|
||||||
|
|
||||||
|
|
@ -110,6 +115,24 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
200, json.dumps({"queued": count}) + "\n", "application/json; charset=utf-8"
|
200, json.dumps({"queued": count}) + "\n", "application/json; charset=utf-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _serve_dequeue(self, query: dict[str, list[str]]):
|
||||||
|
# Remove one upcoming track by the opaque id handed out by /queue.
|
||||||
|
# Proxied here by the stream. Unknown id (already played, or gone) → 404.
|
||||||
|
raw = (query.get("id") or [""])[0].strip()
|
||||||
|
try:
|
||||||
|
entry_id = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
self._text(400, "missing or invalid id\n")
|
||||||
|
return
|
||||||
|
removed = self.server.queue.remove(entry_id)
|
||||||
|
if not removed:
|
||||||
|
self._text(404, "not in queue\n")
|
||||||
|
return
|
||||||
|
self._text(
|
||||||
|
200, json.dumps({"removed": True}) + "\n",
|
||||||
|
"application/json; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
def _serve_share(self, query: dict[str, list[str]]):
|
def _serve_share(self, query: dict[str, list[str]]):
|
||||||
# Mint a public Subsonic share for one song id, on demand. Called by the
|
# Mint a public Subsonic share for one song id, on demand. Called by the
|
||||||
# stream when a listener clicks a subsonic track's source link, so no
|
# stream when a listener clicks a subsonic track's source link, so no
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,16 @@ class Database:
|
||||||
(path, track_key),
|
(path, track_key),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def forget_download(self, path: str) -> None:
|
||||||
|
"""Drop a cache-file row without touching history.
|
||||||
|
|
||||||
|
Used when a queued-but-unplayed track is removed from the queue: its
|
||||||
|
row (played_at NULL) would otherwise linger forever, since retention
|
||||||
|
only ever considers already-played files.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._conn.execute("DELETE FROM cache_files WHERE path = ?", (path,))
|
||||||
|
|
||||||
def played_files(self, limit: int) -> list[str]:
|
def played_files(self, limit: int) -> list[str]:
|
||||||
"""Files already aired, newest first (the stream's fallback pool).
|
"""Files already aired, newest first (the stream's fallback pool).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,10 @@ class TrackQueue:
|
||||||
references), then the automatic radio buffer — the same order
|
references), then the automatic radio buffer — the same order
|
||||||
``pop_next`` serves them. Mirrors the fields exposed for the current
|
``pop_next`` serves them. Mirrors the fields exposed for the current
|
||||||
track (see ``annotate_uri``): a source ``url`` only for http(s)
|
track (see ``annotate_uri``): a source ``url`` only for http(s)
|
||||||
locators.
|
locators. Each entry also carries an opaque ``id`` (the track object's
|
||||||
|
handle) that ``remove`` accepts to drop it from the queue — stable
|
||||||
|
while the track sits in a buffer, and immune to reordering between
|
||||||
|
polls (unlike a positional index).
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
upcoming = (
|
upcoming = (
|
||||||
|
|
@ -167,6 +170,7 @@ class TrackQueue:
|
||||||
items = []
|
items = []
|
||||||
for track in upcoming:
|
for track in upcoming:
|
||||||
entry = {
|
entry = {
|
||||||
|
"id": str(id(track)),
|
||||||
"title": track.title,
|
"title": track.title,
|
||||||
"artist": track.artist,
|
"artist": track.artist,
|
||||||
"origin": track.origin,
|
"origin": track.origin,
|
||||||
|
|
@ -176,6 +180,40 @@ class TrackQueue:
|
||||||
items.append(entry)
|
items.append(entry)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
def remove(self, entry_id: int) -> bool:
|
||||||
|
"""Drop an upcoming track from the queue by its ``snapshot`` id.
|
||||||
|
|
||||||
|
``entry_id`` is the ``id()`` handle exposed by :meth:`snapshot`, stable
|
||||||
|
while the track sits in a buffer. A still-pending request simply
|
||||||
|
vanishes; an already-fetched entry also has its cache file removed,
|
||||||
|
since once out of the queue it would neither play nor be LRU-evicted
|
||||||
|
(eviction only touches files that have aired). Returns whether anything
|
||||||
|
matched.
|
||||||
|
"""
|
||||||
|
removed_path = None
|
||||||
|
with self._lock:
|
||||||
|
for track in self._requests:
|
||||||
|
if id(track) == entry_id:
|
||||||
|
self._requests.remove(track)
|
||||||
|
return True
|
||||||
|
for buf in (self._ready_req, self._ready):
|
||||||
|
for item in buf:
|
||||||
|
if id(item[1]) == entry_id:
|
||||||
|
buf.remove(item)
|
||||||
|
removed_path = item[0]
|
||||||
|
break
|
||||||
|
if removed_path is not None:
|
||||||
|
break
|
||||||
|
if removed_path is None:
|
||||||
|
return False
|
||||||
|
self._db.forget_download(str(removed_path))
|
||||||
|
try:
|
||||||
|
removed_path.unlink(missing_ok=True)
|
||||||
|
log.info("removed queued %s", removed_path.name)
|
||||||
|
except OSError:
|
||||||
|
log.exception("could not remove queued file %s", removed_path)
|
||||||
|
return True
|
||||||
|
|
||||||
# --- serving ----------------------------------------------------------
|
# --- serving ----------------------------------------------------------
|
||||||
|
|
||||||
def pop_next(self) -> tuple[Path, Track] | None:
|
def pop_next(self) -> tuple[Path, Track] | None:
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,13 @@
|
||||||
.queue .q-num { color: #6b6480; font-size: .8rem; font-variant-numeric: tabular-nums;
|
.queue .q-num { color: #6b6480; font-size: .8rem; font-variant-numeric: tabular-nums;
|
||||||
min-width: 1.2em; text-align: right; }
|
min-width: 1.2em; text-align: right; }
|
||||||
.queue .q-meta { flex: 1; min-width: 0; }
|
.queue .q-meta { flex: 1; min-width: 0; }
|
||||||
|
/* Retrait d'un morceau à venir : croix discrète, virant au rouge tamisé au
|
||||||
|
survol pour signaler l'action destructive. Bouton nu, aligné sur le titre. */
|
||||||
|
.queue .q-act { color: #7d768f; font-size: .95rem; line-height: 1;
|
||||||
|
background: none; border: 0; padding: 0; cursor: pointer;
|
||||||
|
transition: color .15s; }
|
||||||
|
.queue .q-act:hover { color: #e08b9b; }
|
||||||
|
.queue .q-act:disabled { opacity: .5; cursor: default; }
|
||||||
/* Ajout à la file : un input d'URL + bouton, calqués sur le bloc « share ».
|
/* 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
|
Le message de retour s'affiche discrètement sous le formulaire, en rouge
|
||||||
tamisé quand c'est une erreur. */
|
tamisé quand c'est une erreur. */
|
||||||
|
|
@ -473,11 +480,33 @@
|
||||||
? `<a href="${escapeHtml(u)}" target="_blank" rel="noopener">${t}</a>`
|
? `<a href="${escapeHtml(u)}" target="_blank" rel="noopener">${t}</a>`
|
||||||
: t;
|
: t;
|
||||||
const meta = `<div class="q-meta"><div class="h-title">${titleHtml}</div>${artist}</div>`;
|
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>`;
|
// Croix de retrait, portant l'id opaque de l'entrée (fourni par
|
||||||
|
// /queue). Absent (ancien ingest sans id) → pas de bouton.
|
||||||
|
const id = (m.id || "").toString();
|
||||||
|
const rm = id
|
||||||
|
? `<button class="q-act" type="button" data-id="${escapeHtml(id)}" title="Retirer de la file" aria-label="Retirer de la file">✕</button>`
|
||||||
|
: "";
|
||||||
|
return `<li><div class="q-item"><span class="q-num">${i + 1}</span>${meta}${rm}</div></li>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
} catch (e) { /* keep last known values */ }
|
} catch (e) { /* keep last known values */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retrait d'un morceau de la file : délégation de clic sur la liste. On POST
|
||||||
|
// l'id au stream (relayé à l'ingest), puis on rafraîchit la file. Un échec
|
||||||
|
// (entrée déjà passée entre-temps) est simplement ignoré : le prochain
|
||||||
|
// rafraîchissement remettra l'affichage d'aplomb.
|
||||||
|
queueList.addEventListener("click", async (e) => {
|
||||||
|
const btn = e.target.closest(".q-act");
|
||||||
|
if (!btn) return;
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
if (!id) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await fetch("/dequeue?id=" + encodeURIComponent(id), { method: "POST" });
|
||||||
|
} catch (err) { /* ignore */ }
|
||||||
|
pollQueue();
|
||||||
|
});
|
||||||
|
|
||||||
// Ajout à la file : on POST l'URL au stream, qui la relaie à l'ingest. Ce
|
// 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
|
// 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
|
// en file prioritaire — le prochain morceau diffusé sera la demande. On
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,29 @@ harbor.http.register(
|
||||||
end
|
end
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Retirer un morceau de la file d'attente. Symétrique de /enqueue : le player
|
||||||
|
# n'a pas accès au réseau interne, on relaie donc la demande (l'`id` opaque
|
||||||
|
# fourni par /queue) vers l'ingest, qui retire l'entrée correspondante. On
|
||||||
|
# renvoie tel quel son code et son corps JSON ({removed: true} ou une erreur).
|
||||||
|
ingest_dequeue_url = "http://ingest:8080/dequeue"
|
||||||
|
harbor.http.register(
|
||||||
|
port=8000, method="POST", "/dequeue",
|
||||||
|
fun(req, resp) -> begin
|
||||||
|
id = list.assoc(default="", "id", req.query)
|
||||||
|
if id == "" then
|
||||||
|
resp.status_code(400)
|
||||||
|
resp.data("missing id")
|
||||||
|
else
|
||||||
|
body = http.post(
|
||||||
|
data="", timeout=10.0, "#{ingest_dequeue_url}?id=#{url.encode(id)}"
|
||||||
|
)
|
||||||
|
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
|
# 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
|
# 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).
|
# est immédiat (le prochain /next est demandé au daemon dans la foulée).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue