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

261 lines
11 KiB
Markdown

# 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`).