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 <noreply@anthropic.com>
This commit is contained in:
Michal Tuszowski
2026-07-21 19:43:14 +02:00
parent 7f652aa9db
commit 298f4b111e
4 changed files with 165 additions and 42 deletions
+72 -19
View File
@@ -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
+68 -12
View File
@@ -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")
+7 -5
View File
@@ -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
+18 -6
View File
@@ -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