# 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](https://www.navidrome.org/docs/usage/features/plugins/), [plugin capability list](https://github.com/navidrome/navidrome/blob/master/plugins/README.md). ## 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: ```bash 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: ```bash 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: ```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 ### Docker (recommended) ```bash 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 ```bash cd navidrome_bridge pip install -r requirements.txt set -a; . ./.env; set +a python3 bridge.py ``` As a systemd unit: ```ini [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`](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`).