From 298f4b111ed02dd6b5de2fa4060cbd17459100a3 Mon Sep 17 00:00:00 2001 From: Michal Tuszowski Date: Tue, 21 Jul 2026 19:43:14 +0200 Subject: [PATCH] navidrome_bridge: add dropbox mode writing straight to the request spool Reading radio_conjurer.liq shows request.playlist is not a Liquidsoap playlist at all - it is a drop box. queue_processing() runs every 60s, reads every line, pushes each into request.queue() as a URI, then deletes the file and recreates it empty. That changes what writing to it means, so the mode is now named and documented for what it is. BRIDGE_MODE=dropbox (with "playlist" kept as an alias) appends the exact translated path to that file. Because the lines are pushed as URIs it is an exact hand-off - no keyword search - and it involves neither betoniarka nor the bot, just the file and Liquidsoap. The liq also puts requests_queue first in the fallback and does not apply the check_next replay guard to it, so a request interrupts the rotation and plays even if the track ran recently; both are now documented rather than left to be discovered. Fixes found while wiring this up: * the existence check was unconditional while the library mount was documented as optional, so a drop-box-only setup could never queue anything. It is now BRIDGE_VERIFY_FILE_EXISTS (auto|true|false), defaulting to checking only when the library is actually visible; * a missing parent dir was silently created, which would swallow requests into the container's own filesystem when the radio's data dir was not mounted. It is now a hard error naming the likely cause. Verified: alias resolves; append/drain/append cycle against a simulation of the liq's read-remove-recreate; both misconfigurations raise instead of silently succeeding; drop-box-only path works with the library absent; api mode unchanged, still prepending the sentinel that wyszukaj() discards. Co-Authored-By: Claude Opus 4.8 --- navidrome_bridge/README.md | 91 +++++++++++++++++++++++++++-------- navidrome_bridge/bridge.py | 80 +++++++++++++++++++++++++----- navidrome_bridge/compose.yaml | 12 +++-- navidrome_bridge/env.example | 24 ++++++--- 4 files changed, 165 insertions(+), 42 deletions(-) diff --git a/navidrome_bridge/README.md b/navidrome_bridge/README.md index 99b0d63..9a9ed75 100644 --- a/navidrome_bridge/README.md +++ b/navidrome_bridge/README.md @@ -14,14 +14,17 @@ request queue. One click, from whatever you already listen with. > * it **bypasses Kondziu and his API entirely**. It never talks to the Discord > bot, never touches `/prepped_tracks`, and needs no cog enabled. The bot can > be dead and this keeps working; -> * it talks to exactly two things: **Navidrome's Subsonic API** and -> **betoniarka** (the radio operator). +> * it reads **Navidrome's Subsonic API**, and hands the track to the radio by +> one of two routes — through **betoniarka**, or straight into the file +> Liquidsoap drains. > > ``` -> Navidrome ──(Subsonic API: read stars, optional unstar)──► bridge -> bridge ──(/request_radio_file, or the request playlist)──► betoniarka ──► Liquidsoap +> Navidrome ──(Subsonic API: read stars, optional unstar)──► bridge > -> Kondziu (Discord bot) ....................... not involved +> bridge ──(api mode: /request_radio_file)──► betoniarka ──┐ +> bridge ──(dropbox mode: request.playlist)────────────────┴─► Liquidsoap +> +> Kondziu (Discord bot) ....................... not involved in either > ``` --- @@ -58,8 +61,10 @@ Sources: [Navidrome plugin docs](https://www.navidrome.org/docs/usage/features/p 1. You star a track in Navidrome (web UI, phone, anything Subsonic). 2. The bridge polls `getStarred2` and notices the new star. 3. It translates the file's path from Navidrome's mount point to the radio's. -4. It hands that track to betoniarka, which appends it to the request playlist; - Liquidsoap is already watching that file and picks it up. +4. It hands that track over — either to betoniarka (`api` mode) or by writing + the path straight into the request spool (`dropbox` mode). Either way the + path ends up as a line in `request.playlist`, which Liquidsoap drains into + its request queue once a minute. 5. Optionally it un-stars the track, so the heart behaves like a "send" button you can press again next time. @@ -94,12 +99,13 @@ BRIDGE_DRY_RUN=true docker compose up # logs every translation, sends nothi ## Two delivery modes -| | `api` (default) | `playlist` | +| | `api` (default) | `dropbox` | |---|---|---| -| How | `POST /request_radio_file` | appends to `request.playlist` | -| Needs | network access to betoniarka | the radio's data dir mounted here | -| Accuracy | fuzzy — the radio re-finds the track from keywords | exact path | -| Runs anywhere | yes | only where the radio's volume is reachable | +| How | `POST /request_radio_file` | writes the exact path into the file Liquidsoap drains | +| Goes through | betoniarka | nothing — straight to Liquidsoap | +| Needs | network access to betoniarka | that one file reachable here | +| Accuracy | fuzzy — betoniarka re-finds the track from keywords | exact hand-off | +| Auth | `CONJURER_API_KEY` must match | none needed | `api` is the default because it keeps the bridge genuinely external — no shared filesystem, no assumptions about where the radio stores things. @@ -109,8 +115,51 @@ path, and re-searches its own library. The bridge feeds it every distinctive token of the translated path (`Rock`, `Behemoth`, `Demigod`, `01`, `Ora`, `Pro`, `Nobis`), which in testing resolved to the exact intended file even against deliberately confusing libraries — same artist, a live version of the -same title, another album of the same name. It is still a search: if you need a -guarantee, use `playlist` mode. +same title, another album of the same name. It is still a search. + +### `dropbox` mode — writing straight to the request spool + +`request.playlist` is **not a Liquidsoap playlist**. In `radio_conjurer.liq` it +is a drop box that gets drained every 60 seconds: + +```liquidsoap +def queue_processing() + text = file.lines("/srv/betoniarka/data/request.playlist") + if text != [] then + list.iter(fun(item) -> requests_queue.push.uri(item), text) + file.remove("/srv/betoniarka/data/request.playlist") # then recreated empty + end +end +thread.run(every=60., queue_processing) +``` + +Set `BRIDGE_MODE=dropbox` and point `BETONIARKA_REQUEST_PLAYLIST` at that file; +the bridge appends one absolute path per line. What that buys you: + +- **Exact hand-off.** Lines are pushed as URIs, so the radio plays *that* file — + no search, no chance of a different take of the same song. +- **Nothing in the middle.** No betoniarka, no bot, no API key — just a file and + Liquidsoap. This is the most direct route there is. +- **Priority.** `requests_queue` is first in the `fallback`, so a request cuts + into the rotation at the next track boundary. +- **No replay guard.** The 10-hour "don't repeat" check (`check_next=check`) + is attached to the regular playlists, *not* to the request queue — so a + request plays even if that track ran recently. + +Costs and caveats: + +- Up to 60 s before Liquidsoap notices, on top of the bridge's own poll. +- The file is emptied under you by design; never treat it as durable state. The + bridge appends in a single `O_APPEND` write, so a drain can never catch half a + path — but a line appended in the narrow window between the liq's `file.lines` + and `file.remove` is dropped. It is rare, and re-starring re-sends. +- The name is singular: `request.playlist`. + +Enable it by also mounting the radio's data dir — see the commented volume in +`compose.yaml`. Mounting the music library too is optional: with +`BRIDGE_VERIFY_FILE_EXISTS=auto` the bridge verifies paths only when the library +is actually visible, so a drop-box-only container works without it. A missing +data dir is always a hard error rather than a silently-swallowed request. ## Install @@ -132,8 +181,8 @@ Path translation: /music -> /srv/betoniarka/music First run: ignoring 128 already-starred tracks ``` -For `playlist` mode, also set `BRIDGE_MODE=playlist` and uncomment the -betoniarka volume in `compose.yaml`. +For `dropbox` mode, also set `BRIDGE_MODE=dropbox` and uncomment the +betoniarka data volume in `compose.yaml`. ### Without Docker @@ -174,7 +223,9 @@ Every setting, its default, and what it does is documented inline in | `BETONIARKA_LIBRARY_ROOT` | `/srv/betoniarka/music` | the radio's mount point | | `BETONIARKA_URL` | `http://localhost:5005` | | | `CONJURER_API_KEY` | empty | must match the radio's, if it has one | -| `BRIDGE_MODE` | `api` | `api` or `playlist` | +| `BRIDGE_MODE` | `api` | `api` or `dropbox` | +| `BETONIARKA_REQUEST_PLAYLIST` | `/srv/betoniarka/data/request.playlist` | dropbox mode: the file the liq drains | +| `BRIDGE_VERIFY_FILE_EXISTS` | `auto` | check the path exists before queueing | | `BRIDGE_UNSTAR_AFTER_QUEUE` | `false` | makes the heart a repeatable "send" button | | `BRIDGE_DRY_RUN` | `false` | translate and log, send nothing | @@ -186,8 +237,10 @@ Every setting, its default, and what it does is documented inline in | `Subsonic getStarred2 failed: 40 Wrong username or password` | bad credentials | | Starring does nothing, no log line | already-starred tracks are skipped on first run by design — un-star and re-star | | `Failed to queue …: 401` | `CONJURER_API_KEY` does not match the radio's | -| Track queued but the radio plays something else | `api` mode picked a different match — switch to `playlist` | -| `… not visible here` (playlist mode) | the library roots don't line up, or the volume isn't mounted | +| Track queued but the radio plays something else | `api` mode picked a different match — switch to `dropbox` | +| `… not visible here` | the library roots don't line up, or the library isn't mounted | +| `… does not exist here - is the radio's data dir mounted?` | dropbox mode without the data dir volume | +| dropbox: queued, file stays empty, nothing plays | Liquidsoap drained it — that is the file working as intended; check the radio log | | Nothing plays although the playlist grew | check Liquidsoap is watching `request.playlist` | Retries are automatic: a track that fails to queue is not marked as handled, so diff --git a/navidrome_bridge/bridge.py b/navidrome_bridge/bridge.py index cc1dd2c..ece903a 100644 --- a/navidrome_bridge/bridge.py +++ b/navidrome_bridge/bridge.py @@ -56,12 +56,23 @@ BETONIARKA_LIBRARY_ROOT = _env("BETONIARKA_LIBRARY_ROOT", "/srv/betoniarka/music BETONIARKA_URL = _env("BETONIARKA_URL", "http://localhost:5005").rstrip("/") CONJURER_API_KEY = _env("CONJURER_API_KEY") -# "api" - POST /request_radio_file (works over the network; the radio -# re-finds the track by keywords, so it is a fuzzy match) -# "playlist" - append the exact translated path to the request playlist -# (exact, but needs the radio's data dir reachable here) +# "api" - POST /request_radio_file. Works over the network, but betoniarka +# re-finds the track from keywords, so it is a fuzzy match. +# "dropbox" - write the exact path straight into the spool file that +# radio_conjurer.liq drains. Exact, and involves neither betoniarka +# nor the bot: just this file and Liquidsoap. ("playlist" is +# accepted as an alias for this mode.) MODE = _env("BRIDGE_MODE", "api").strip().lower() +if MODE == "playlist": + MODE = "dropbox" + +# The editable drop box itself. Point this at the exact file the liq reads - +# note the name is singular, request.playlist. REQUEST_PLAYLIST = Path(_env("BETONIARKA_REQUEST_PLAYLIST", "/srv/betoniarka/data/request.playlist")) +# Whether to confirm the translated path really exists before queueing it. +# "auto" (default) checks only when the library happens to be mounted here, so +# a drop-box-only setup is not forced to mount the whole music collection. +VERIFY_FILE_EXISTS = _env("BRIDGE_VERIFY_FILE_EXISTS", "auto").strip().lower() POLL_SECONDS = int(_env("BRIDGE_POLL_SECONDS", "15")) STATE_FILE = Path(_env("BRIDGE_STATE_FILE", "/data/queued.json")) @@ -182,19 +193,55 @@ def queue_via_api(radio_path: str) -> None: response.raise_for_status() -def queue_via_playlist(radio_path: str) -> None: - if not Path(radio_path).exists(): +def _should_verify() -> bool: + if VERIFY_FILE_EXISTS in ("1", "true", "yes", "on"): + return True + if VERIFY_FILE_EXISTS in ("0", "false", "no", "off"): + return False + # "auto": only meaningful when the library is actually mounted here. + return Path(BETONIARKA_LIBRARY_ROOT).is_dir() + + +def queue_via_dropbox(radio_path: str) -> None: + """Append the exact path to the spool file Liquidsoap drains. + + radio_conjurer.liq treats this file as a drop box, not as a playlist: + + text = file.lines(".../request.playlist") + list.iter(fun(item) -> requests_queue.push.uri(item), text) + file.remove(...) # then recreated empty + + every 60 seconds. Consequences worth knowing: + + * lines are pushed as URIs, so this is an exact hand-off - no searching, + no chance of the radio picking a different take of the same song; + * pickup takes up to a minute; + * requests_queue sits first in the fallback, so a request interrupts the + normal rotation at the next track boundary; + * the queue is not subject to the check_next replay guard that the regular + playlists use, so a request plays even if the track ran recently; + * the file is emptied under us by design - never treat it as durable state. + """ + if _should_verify() and not Path(radio_path).exists(): raise RuntimeError( f"{radio_path} not visible here - check the two library roots and the mount" ) - REQUEST_PLAYLIST.parent.mkdir(parents=True, exist_ok=True) + # A missing parent almost always means the radio's data dir was not + # mounted; creating it would silently swallow requests into this + # container's own filesystem, so fail loudly instead. + if not REQUEST_PLAYLIST.parent.is_dir(): + raise RuntimeError( + f"{REQUEST_PLAYLIST.parent} does not exist here - is the radio's data dir mounted?" + ) + # One append per request: O_APPEND makes a single small write atomic, so a + # concurrent drain can never see a half-written path. with REQUEST_PLAYLIST.open("a", encoding="utf-8") as handle: handle.write(radio_path + "\n") def queue(radio_path: str) -> None: - if MODE == "playlist": - queue_via_playlist(radio_path) + if MODE == "dropbox": + queue_via_dropbox(radio_path) else: queue_via_api(radio_path) @@ -279,11 +326,20 @@ def main() -> int: if missing: LOG.error("FATAL: missing required config: %s", ", ".join(missing)) return 1 - if MODE not in ("api", "playlist"): - LOG.error("FATAL: BRIDGE_MODE must be 'api' or 'playlist', got %r", MODE) + if MODE not in ("api", "dropbox"): + LOG.error( + "FATAL: BRIDGE_MODE must be 'api' or 'dropbox' ('playlist' = 'dropbox'), got %r", + MODE, + ) return 1 - LOG.info("Navidrome %s -> betoniarka %s (mode=%s)", NAVIDROME_URL, BETONIARKA_URL, MODE) + if MODE == "dropbox": + # Neither betoniarka nor the bot is involved in this mode. + LOG.info("Navidrome %s -> drop box %s (mode=dropbox)", NAVIDROME_URL, REQUEST_PLAYLIST) + LOG.info("Liquidsoap drains that file every 60s; path verification: %s", + "on" if _should_verify() else "off") + else: + LOG.info("Navidrome %s -> betoniarka %s (mode=api)", NAVIDROME_URL, BETONIARKA_URL) LOG.info("Path translation: %s -> %s", NAVIDROME_LIBRARY_ROOT, BETONIARKA_LIBRARY_ROOT) if DRY_RUN: LOG.info("DRY RUN - nothing will be queued") diff --git a/navidrome_bridge/compose.yaml b/navidrome_bridge/compose.yaml index 72c06b4..7bcdc53 100644 --- a/navidrome_bridge/compose.yaml +++ b/navidrome_bridge/compose.yaml @@ -15,12 +15,14 @@ services: # Which starred tracks were already sent to the radio. - ./data:/data - # ONLY for BRIDGE_MODE=playlist: the radio's data dir, so the exact path - # can be appended to request.playlist. Leave commented out for the - # default "api" mode, which needs no shared filesystem at all. + # ONLY for BRIDGE_MODE=dropbox: the radio's data dir, so the exact path + # can be appended to request.playlist - the file radio_conjurer.liq + # drains every 60s. Leave commented out for the default "api" mode, + # which needs no shared filesystem at all. # - /srv/betoniarka/data:/srv/betoniarka/data - # Optional, and only useful in playlist mode: mounting the library here + # Optional, and only useful in dropbox mode: mounting the library here # (read-only) lets the bridge verify a translated path really exists - # before queueing it, turning a silent radio no-op into a clear error. + # before queueing it (BRIDGE_VERIFY_FILE_EXISTS=auto), turning a silent + # radio no-op into a clear error. # - /srv/betoniarka/music:/srv/betoniarka/music:ro diff --git a/navidrome_bridge/env.example b/navidrome_bridge/env.example index 20efc88..8d24ede 100644 --- a/navidrome_bridge/env.example +++ b/navidrome_bridge/env.example @@ -20,15 +20,27 @@ BETONIARKA_URL=http://radio-vm:5005 CONJURER_API_KEY= # How the request reaches the radio: -# api - POST /request_radio_file over the network. No shared filesystem. -# The radio re-finds the track from keywords, so it is a fuzzy -# match (accurate in practice - full path tokens are very -# distinctive - but not guaranteed). -# playlist - append the exact translated path to the request playlist. -# Exact, but needs the radio's data dir mounted here. +# api - POST /request_radio_file over the network. No shared filesystem. +# betoniarka re-finds the track from keywords, so it is a fuzzy +# match (accurate in practice - full path tokens are very +# distinctive - but not guaranteed). +# dropbox - write the exact path straight into the file Liquidsoap drains. +# Exact hand-off, and it involves neither betoniarka nor the bot; +# needs that file reachable here. ("playlist" = same thing.) BRIDGE_MODE=api + +# dropbox mode only: the editable drop box radio_conjurer.liq reads every 60s. +# It pushes each line as a URI and then empties the file. Note the singular +# name - request.playlist, not requests.playlist. BETONIARKA_REQUEST_PLAYLIST=/srv/betoniarka/data/request.playlist +# Confirm the translated path exists before queueing it. +# auto - check only when BETONIARKA_LIBRARY_ROOT is actually mounted here +# (so a drop-box-only setup need not mount the whole library) +# true - always check; refuse to queue what cannot be seen +# false - never check +BRIDGE_VERIFY_FILE_EXISTS=auto + # --- Behaviour ----------------------------------------------------------- # How often to look for newly starred tracks. BRIDGE_POLL_SECONDS=15