diff --git a/ingest/radieo/api.py b/ingest/radieo/api.py index e121586..8816018 100644 --- a/ingest/radieo/api.py +++ b/ingest/radieo/api.py @@ -12,6 +12,9 @@ Endpoints: POST /enqueue?url= -> resolve a yt-dlp URL (single track or whole playlist/ album) and queue it as priority requests; returns {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" """ @@ -87,6 +90,8 @@ class _Handler(BaseHTTPRequestHandler): self._serve_share(parse_qs(parsed.query)) elif parsed.path == "/enqueue": self._serve_enqueue(parse_qs(parsed.query)) + elif parsed.path == "/dequeue": + self._serve_dequeue(parse_qs(parsed.query)) else: self._text(404, "not found\n") @@ -110,6 +115,24 @@ class _Handler(BaseHTTPRequestHandler): 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]]): # 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 diff --git a/ingest/radieo/db.py b/ingest/radieo/db.py index 01519cf..6d40dfb 100644 --- a/ingest/radieo/db.py +++ b/ingest/radieo/db.py @@ -141,6 +141,16 @@ class Database: (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]: """Files already aired, newest first (the stream's fallback pool). diff --git a/ingest/radieo/queue.py b/ingest/radieo/queue.py index c82e4f6..3751fd9 100644 --- a/ingest/radieo/queue.py +++ b/ingest/radieo/queue.py @@ -156,7 +156,10 @@ class TrackQueue: references), then the automatic radio buffer — the same order ``pop_next`` serves them. Mirrors the fields exposed for the current 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: upcoming = ( @@ -167,6 +170,7 @@ class TrackQueue: items = [] for track in upcoming: entry = { + "id": str(id(track)), "title": track.title, "artist": track.artist, "origin": track.origin, @@ -176,6 +180,40 @@ class TrackQueue: items.append(entry) 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 ---------------------------------------------------------- def pop_next(self) -> tuple[Path, Track] | None: diff --git a/stream/index.html b/stream/index.html index 2fbd4a8..390524e 100644 --- a/stream/index.html +++ b/stream/index.html @@ -157,6 +157,13 @@ .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; } + /* 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 ». Le message de retour s'affiche discrètement sous le formulaire, en rouge tamisé quand c'est une erreur. */ @@ -473,11 +480,33 @@ ? `${t}` : t; const meta = `
${titleHtml}
${artist}
`; - return `
  • ${i + 1}${meta}
  • `; + // 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 + ? `` + : ""; + return `
  • ${i + 1}${meta}${rm}
  • `; }).join(""); } 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 // 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 diff --git a/stream/radio.liq b/stream/radio.liq index 084fae3..6ff8ec6 100644 --- a/stream/radio.liq +++ b/stream/radio.liq @@ -316,6 +316,29 @@ harbor.http.register( 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 # 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).