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 = `
`; - return `