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:
+72
-19
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user