298f4b111e
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>
261 lines
11 KiB
Markdown
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`).
|