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>
11 KiB
Navidrome → betoniarka bridge
Star the track you are listening to in Navidrome and it lands in the radio's request queue. One click, from whatever you already listen with.
⚠️ This is an EXTERNAL component
It lives in this repo for convenience, but it is not part of Conjurer:
- it imports nothing from the bot's codebase — it is a standalone script with a single dependency (
requests);- 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 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 ──(api mode: /request_radio_file)──► betoniarka ──┐ bridge ──(dropbox mode: request.playlist)────────────────┴─► Liquidsoap Kondziu (Discord bot) ....................... not involved in either
Why this is not a .ndp Navidrome plugin
Navidrome does have a plugin system (WASM/Extism, .ndp packages), so this is
worth spelling out. Its plugin capabilities are:
MetadataAgent, Scrobbler, Lyrics, SonicSimilarity, TaskWorker,
Lifecycle, SchedulerCallback, WebSocketCallback.
Two things follow, and both kill the "button in the Navidrome UI" idea:
- There is no UI extension capability. A plugin cannot add a button, menu entry or context action to the web interface. Plugins are backend extensions running in a WASM sandbox.
- There is no star/love event. Nothing fires when a user favourites a track, so even a headless plugin could not react to the click.
The remaining plugin-shaped option would be the Scrobbler capability, which
receives every track you play — that is a firehose, not a one-click request.
So instead of inventing a button, this uses the button Navidrome already has: the star/love control. That choice has a real advantage over a hypothetical custom button — it works in every Subsonic client, so you can also queue a track from your phone.
Sources: Navidrome plugin docs, plugin capability list.
How it works
- You star a track in Navidrome (web UI, phone, anything Subsonic).
- The bridge polls
getStarred2and notices the new star. - It translates the file's path from Navidrome's mount point to the radio's.
- It hands that track over — either to betoniarka (
apimode) or by writing the path straight into the request spool (dropboxmode). Either way the path ends up as a line inrequest.playlist, which Liquidsoap drains into its request queue once a minute. - Optionally it un-stars the track, so the heart behaves like a "send" button you can press again next time.
Path translation
Both sides index the same mp3 files under different mount points, which is the one thing you must get right:
Navidrome sees: /music/Rock/Behemoth/Demigod/01 - Ora Pro Nobis.mp3
└─────┘ NAVIDROME_LIBRARY_ROOT
radio sees: /srv/betoniarka/music/Rock/Behemoth/Demigod/01 - Ora Pro Nobis.mp3
└──────────────────┘ BETONIARKA_LIBRARY_ROOT
The bridge strips the first root and re-roots the remainder at the second. Paths that Navidrome reports relative to its library are handled too, so both forms give the same result.
Find your two values with:
docker exec navidrome printenv ND_MUSICFOLDER # Navidrome side
docker exec conjurer-radio printenv BETONIARKA_MUSIC # radio side
Then confirm they line up before going live:
BRIDGE_DRY_RUN=true docker compose up # logs every translation, sends nothing
Two delivery modes
api (default) |
dropbox |
|
|---|---|---|
| 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.
About the fuzziness: betoniarka's request endpoint takes keywords, not a
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.
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:
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_queueis first in thefallback, 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_APPENDwrite, so a drain can never catch half a path — but a line appended in the narrow window between the liq'sfile.linesandfile.removeis 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
Docker (recommended)
cd navidrome_bridge
cp env.example .env
# edit .env: Navidrome URL + credentials, the two library roots, betoniarka URL
docker compose up -d --build
docker compose logs -f
Expected on a healthy start:
Navidrome http://navidrome:4533 -> betoniarka http://radio-vm:5005 (mode=api)
Path translation: /music -> /srv/betoniarka/music
First run: ignoring 128 already-starred tracks
For dropbox mode, also set BRIDGE_MODE=dropbox and uncomment the
betoniarka data volume in compose.yaml.
Without Docker
cd navidrome_bridge
pip install -r requirements.txt
set -a; . ./.env; set +a
python3 bridge.py
As a systemd unit:
[Unit]
Description=Navidrome to betoniarka request bridge
After=network-online.target
[Service]
EnvironmentFile=/opt/conjurer/navidrome_bridge/.env
ExecStart=/usr/bin/python3 /opt/conjurer/navidrome_bridge/bridge.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
Configuration
Every setting, its default, and what it does is documented inline in
env.example. The ones that matter most:
| Variable | Default | Notes |
|---|---|---|
NAVIDROME_URL |
http://localhost:4533 |
|
NAVIDROME_USER / NAVIDROME_PASSWORD |
— | required; sent as a salted token, never in clear |
NAVIDROME_LIBRARY_ROOT |
/music |
Navidrome's mount point |
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 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 |
Troubleshooting
| Symptom | Cause |
|---|---|
FATAL: missing required config |
NAVIDROME_USER / NAVIDROME_PASSWORD unset |
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 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 the next poll tries it again.
Notes and limitations
- Polling, not push. Up to
BRIDGE_POLL_SECONDS(default 15s) between the click and the queue. Navidrome exposes no star webhook. - Stars are the trigger. If you also use stars as favourites, either accept
that favouriting queues a track, or turn on
BRIDGE_UNSTAR_AFTER_QUEUEand treat the heart purely as a send button. - One Navidrome user. It watches the stars of the account it logs in as.
- No de-duplication against the radio's queue — star the same track twice (with un-star on) and it is requested twice.
- Credentials live in
.env; keep it out of git (the repo's.gitignorealready covers.env).