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