Files
conjurer/navidrome_bridge/README.md
T
Michal Tuszowski 7f652aa9db navidrome_bridge: one-click radio requests from Navidrome
Star a track in Navidrome and it lands in betoniarka's request queue.

EXTERNAL component: imports nothing from Conjurer and bypasses the Discord bot
and its API entirely. It speaks only to Navidrome's Subsonic API and to the
radio operator, so the bot can be down and this keeps working.

Not shipped as a .ndp plugin, deliberately. Navidrome's plugin capabilities are
MetadataAgent, Scrobbler, Lyrics, SonicSimilarity, TaskWorker, Lifecycle,
SchedulerCallback and WebSocketCallback - there is no UI extension capability
(a plugin cannot add a button) and no star/love event to react to. The only
plugin-shaped alternative, Scrobbler, sees every track played, which is a
firehose rather than a one-click request. So the trigger is Navidrome's own
star control, which also works from any Subsonic client including phones.
The README documents this with sources.

Both sides index the same library under different mount points, so every path
is translated between the two roots; paths reported relative to Navidrome's
library are handled as well as absolute ones.

Two delivery modes: "api" (default) posts to /request_radio_file and needs no
shared filesystem, at the cost of the radio re-finding the track by keywords;
"playlist" appends the exact translated path and is exact but needs the radio's
data dir mounted. The api path prepends a sentinel token because betoniarka's
wyszukaj() drops lista_slow[0], where the Discord command word normally sits.

Verified: path translation for both absolute and relative forms; keyword
extraction (no regex metacharacters, extension dropped, Polish characters
intact); and an end-to-end check feeding the generated keywords through
betoniarka's real wyszukaj() against a library seeded with near-miss traps
(same artist, live version of the same title, same album name under another
artist) - it resolved to the exact intended file.

Docker/compose/systemd install paths documented. Not run against a live
Navidrome or radio - no instance reachable from here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:28:46 +02:00

7.9 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 talks to exactly two things: Navidrome's Subsonic API and betoniarka (the radio operator).
  Navidrome  ──(Subsonic API: read stars, optional unstar)──►  bridge
  bridge     ──(/request_radio_file, or the request playlist)──►  betoniarka ──► Liquidsoap

  Kondziu (Discord bot) ....................... not involved

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 to betoniarka, which appends it to the request playlist; Liquidsoap is already watching that file and picks it up.
  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) playlist
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

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: if you need a guarantee, use playlist mode.

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 playlist mode, also set BRIDGE_MODE=playlist and uncomment the betoniarka 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 playlist
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 playlist
… not visible here (playlist mode) the library roots don't line up, or the volume isn't mounted
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).