Files
conjurer/navidrome_bridge/README.md
T
Michal Tuszowski 298f4b111e 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>
2026-07-21 19:43:14 +02:00

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:

  1. 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.
  2. 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

  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 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.

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_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

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_QUEUE and 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 .gitignore already covers .env).