From 7f652aa9dbc59d8d13fba9456c72251d77568003 Mon Sep 17 00:00:00 2001 From: Michal Tuszowski Date: Tue, 21 Jul 2026 19:28:46 +0200 Subject: [PATCH 1/2] 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 --- .gitignore | 2 +- navidrome_bridge/.gitignore | 3 + navidrome_bridge/Dockerfile | 22 +++ navidrome_bridge/README.md | 207 +++++++++++++++++++ navidrome_bridge/bridge.py | 317 ++++++++++++++++++++++++++++++ navidrome_bridge/compose.yaml | 26 +++ navidrome_bridge/env.example | 48 +++++ navidrome_bridge/requirements.txt | 1 + 8 files changed, 625 insertions(+), 1 deletion(-) create mode 100644 navidrome_bridge/.gitignore create mode 100644 navidrome_bridge/Dockerfile create mode 100644 navidrome_bridge/README.md create mode 100644 navidrome_bridge/bridge.py create mode 100644 navidrome_bridge/compose.yaml create mode 100644 navidrome_bridge/env.example create mode 100644 navidrome_bridge/requirements.txt diff --git a/.gitignore b/.gitignore index fb2094d..c3ac2f8 100644 --- a/.gitignore +++ b/.gitignore @@ -201,4 +201,4 @@ cr_results.json not_in_db.json rr_results.json s_results.json -*.bak \ No newline at end of file +*.bak.DS_Store diff --git a/navidrome_bridge/.gitignore b/navidrome_bridge/.gitignore new file mode 100644 index 0000000..35a47d9 --- /dev/null +++ b/navidrome_bridge/.gitignore @@ -0,0 +1,3 @@ +# Runtime state written by the bridge (which stars were already queued) and by +# the compose file's bind mount. Never belongs in git. +data/ diff --git a/navidrome_bridge/Dockerfile b/navidrome_bridge/Dockerfile new file mode 100644 index 0000000..f63280a --- /dev/null +++ b/navidrome_bridge/Dockerfile @@ -0,0 +1,22 @@ +# Navidrome -> betoniarka request bridge (external component; see README.md). +# +# Build from THIS directory (it is self-contained and shares no code with the +# rest of the repo): +# docker build -t navidrome-bridge . +FROM python:3.13-slim + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY bridge.py ./ + +# Remembers which starred tracks were already queued, so a restart does not +# re-send them. +VOLUME ["/data"] + +# Unbuffered so `docker logs` shows what happened as it happens. +ENV PYTHONUNBUFFERED=1 + +CMD ["python", "bridge.py"] diff --git a/navidrome_bridge/README.md b/navidrome_bridge/README.md new file mode 100644 index 0000000..99b0d63 --- /dev/null +++ b/navidrome_bridge/README.md @@ -0,0 +1,207 @@ +# 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](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 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: + +```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) | `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 + +### 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 `playlist` mode, also set `BRIDGE_MODE=playlist` and uncomment the +betoniarka 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 `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`). diff --git a/navidrome_bridge/bridge.py b/navidrome_bridge/bridge.py new file mode 100644 index 0000000..cc1dd2c --- /dev/null +++ b/navidrome_bridge/bridge.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""Navidrome -> betoniarka request bridge. + +EXTERNAL COMPONENT. This does not import anything from Conjurer and does not +talk to the Discord bot ("Kondziu") or its API. It speaks two protocols only: + + Navidrome <--(Subsonic API, read-only + optional unstar)-- bridge + betoniarka <--(/request_radio_file, or the request playlist)-- bridge + +The one-click trigger is Navidrome's own star/love button: star the track you +are listening to and it lands in the radio's request queue. Navidrome's plugin +system cannot add a button to its UI (see README), so the built-in star is used +as the trigger instead - which has the pleasant side effect of working in every +Subsonic client, phone apps included. + +Both sides index the same mp3 files under different mount points, so every path +is translated between the two roots before being handed to the radio. +""" +import hashlib +import json +import logging +import os +import posixpath +import re +import secrets +import sys +import time +from pathlib import Path + +import requests + +LOG = logging.getLogger("navidrome-bridge") + + +def _env(name: str, default: str = "") -> str: + return os.getenv(name, default) + + +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in ("1", "true", "yes", "on") + + +# ----------------------------------------------------------------- config +NAVIDROME_URL = _env("NAVIDROME_URL", "http://localhost:4533").rstrip("/") +NAVIDROME_USER = _env("NAVIDROME_USER") +NAVIDROME_PASSWORD = _env("NAVIDROME_PASSWORD") + +# The same library seen from two places. Navidrome reports a path; whatever +# prefix it carries is swapped for the radio's mount point. +NAVIDROME_LIBRARY_ROOT = _env("NAVIDROME_LIBRARY_ROOT", "/music").rstrip("/") +BETONIARKA_LIBRARY_ROOT = _env("BETONIARKA_LIBRARY_ROOT", "/srv/betoniarka/music").rstrip("/") + +BETONIARKA_URL = _env("BETONIARKA_URL", "http://localhost:5005").rstrip("/") +CONJURER_API_KEY = _env("CONJURER_API_KEY") + +# "api" - POST /request_radio_file (works over the network; the radio +# re-finds the track by keywords, so it is a fuzzy match) +# "playlist" - append the exact translated path to the request playlist +# (exact, but needs the radio's data dir reachable here) +MODE = _env("BRIDGE_MODE", "api").strip().lower() +REQUEST_PLAYLIST = Path(_env("BETONIARKA_REQUEST_PLAYLIST", "/srv/betoniarka/data/request.playlist")) + +POLL_SECONDS = int(_env("BRIDGE_POLL_SECONDS", "15")) +STATE_FILE = Path(_env("BRIDGE_STATE_FILE", "/data/queued.json")) +# Un-star after queueing, turning the heart into a fire-and-forget "send to +# radio" button. Off by default: most people use stars as actual favourites. +UNSTAR_AFTER_QUEUE = _env_bool("BRIDGE_UNSTAR_AFTER_QUEUE", False) +# Only act on tracks starred from now on, so enabling the bridge does not +# dump a lifetime of favourites into the radio on first run. +SKIP_EXISTING_ON_FIRST_RUN = _env_bool("BRIDGE_SKIP_EXISTING_ON_FIRST_RUN", True) +DRY_RUN = _env_bool("BRIDGE_DRY_RUN", False) + +SUBSONIC_CLIENT = "conjurer-navidrome-bridge" +SUBSONIC_VERSION = "1.16.1" +HTTP_TIMEOUT = 30 + + +# ------------------------------------------------------------- subsonic io +def _auth_params() -> dict: + """Subsonic salted-token auth - the password itself never goes on the wire.""" + salt = secrets.token_hex(8) + token = hashlib.md5( # noqa: S324 - mandated by the Subsonic protocol + (NAVIDROME_PASSWORD + salt).encode("utf-8") + ).hexdigest() + return { + "u": NAVIDROME_USER, + "t": token, + "s": salt, + "v": SUBSONIC_VERSION, + "c": SUBSONIC_CLIENT, + "f": "json", + } + + +def _subsonic(endpoint: str, extra: dict = None) -> dict: + params = _auth_params() + if extra: + params.update(extra) + response = requests.get( + f"{NAVIDROME_URL}/rest/{endpoint}", params=params, timeout=HTTP_TIMEOUT + ) + response.raise_for_status() + payload = response.json().get("subsonic-response", {}) + if payload.get("status") != "ok": + error = payload.get("error", {}) + raise RuntimeError( + f"Subsonic {endpoint} failed: {error.get('code')} {error.get('message')}" + ) + return payload + + +def fetch_starred() -> list: + """Return the starred songs as a list of dicts (may be empty).""" + payload = _subsonic("getStarred2") + songs = payload.get("starred2", {}).get("song", []) + # Subsonic collapses a single-element list into an object in some clients; + # normalise so callers can always iterate. + if isinstance(songs, dict): + songs = [songs] + return songs + + +def unstar(song_id: str) -> None: + _subsonic("unstar", {"id": song_id}) + + +# ------------------------------------------------------- path translation +def translate_path(navidrome_path: str) -> str: + """Map a Navidrome-side path onto the radio's mount point. + + Navidrome may report either an absolute path inside its own library root or + a path relative to it, so both are accepted: any leading library root is + stripped and the remainder is re-rooted at the radio's mount point. + """ + path = navidrome_path.replace("\\", "/").strip() + if NAVIDROME_LIBRARY_ROOT and path.startswith(NAVIDROME_LIBRARY_ROOT): + path = path[len(NAVIDROME_LIBRARY_ROOT):] + return posixpath.join(BETONIARKA_LIBRARY_ROOT, path.lstrip("/")) + + +def keywords_for(radio_path: str) -> list: + """Distinctive path tokens for betoniarka's keyword search. + + Only word characters are kept: the radio feeds each token straight into a + regex, so punctuation would either break the pattern or fail to match its + own sanitised library tokens. + """ + relative = radio_path + if relative.startswith(BETONIARKA_LIBRARY_ROOT): + relative = relative[len(BETONIARKA_LIBRARY_ROOT):] + tokens, seen = [], set() + for token in re.findall(r"\w+", relative, re.UNICODE): + low = token.lower() + if low == "mp3" or len(token) < 2 or low in seen: + continue + seen.add(low) + tokens.append(token) + return tokens + + +# ------------------------------------------------------------ radio io +def _radio_headers() -> dict: + return {"X-Conjurer-Api-Key": CONJURER_API_KEY} if CONJURER_API_KEY else {} + + +def queue_via_api(radio_path: str) -> None: + keywords = keywords_for(radio_path) + if not keywords: + raise RuntimeError(f"no usable keywords for {radio_path}") + # betoniarka's wyszukaj() drops lista_slow[0] - it expects the Discord + # command word there - so a sentinel takes that slot. + payload = {"lista_slow": ["navidrome-bridge"] + keywords} + response = requests.post( + f"{BETONIARKA_URL}/request_radio_file", + json=payload, + headers=_radio_headers(), + timeout=HTTP_TIMEOUT, + ) + response.raise_for_status() + + +def queue_via_playlist(radio_path: str) -> None: + if not Path(radio_path).exists(): + raise RuntimeError( + f"{radio_path} not visible here - check the two library roots and the mount" + ) + REQUEST_PLAYLIST.parent.mkdir(parents=True, exist_ok=True) + with REQUEST_PLAYLIST.open("a", encoding="utf-8") as handle: + handle.write(radio_path + "\n") + + +def queue(radio_path: str) -> None: + if MODE == "playlist": + queue_via_playlist(radio_path) + else: + queue_via_api(radio_path) + + +# --------------------------------------------------------------- state +def load_state() -> set: + try: + with STATE_FILE.open(encoding="utf-8") as handle: + return set(json.load(handle).get("queued", [])) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return set() + + +def save_state(queued: set) -> None: + try: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = STATE_FILE.with_suffix(STATE_FILE.suffix + ".tmp") + with tmp.open("w", encoding="utf-8") as handle: + json.dump({"queued": sorted(queued)}, handle, indent=2) + tmp.replace(STATE_FILE) + except OSError as exc: + LOG.warning("Cannot persist state to %s: %s", STATE_FILE, exc) + + +# ---------------------------------------------------------------- main +def process_once(already: set) -> set: + """One poll cycle. Returns the updated set of handled song ids.""" + songs = fetch_starred() + for song in songs: + song_id = str(song.get("id", "")) + if not song_id or song_id in already: + continue + + navidrome_path = song.get("path", "") + label = f"{song.get('artist', '?')} - {song.get('title', '?')}" + if not navidrome_path: + LOG.warning("Skipping %s (%s): Navidrome returned no path", label, song_id) + already.add(song_id) + continue + + radio_path = translate_path(navidrome_path) + if DRY_RUN: + LOG.info("[dry-run] %s\n navidrome: %s\n radio : %s", + label, navidrome_path, radio_path) + already.add(song_id) + continue + + try: + queue(radio_path) + except (requests.exceptions.RequestException, RuntimeError, OSError) as exc: + # Left out of `already`, so the next cycle retries it. + LOG.error("Failed to queue %s: %s", label, exc) + continue + + LOG.info("Queued for radio: %s -> %s", label, radio_path) + already.add(song_id) + + if UNSTAR_AFTER_QUEUE: + try: + unstar(song_id) + # The star is the trigger, not a record: once un-starred the + # id can legitimately come back, so stop remembering it. + already.discard(song_id) + except (requests.exceptions.RequestException, RuntimeError) as exc: + LOG.warning("Queued but could not un-star %s: %s", label, exc) + return already + + +def main() -> int: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + stream=sys.stdout, + ) + + missing = [ + name for name, value in ( + ("NAVIDROME_USER", NAVIDROME_USER), + ("NAVIDROME_PASSWORD", NAVIDROME_PASSWORD), + ) if not value + ] + if missing: + LOG.error("FATAL: missing required config: %s", ", ".join(missing)) + return 1 + if MODE not in ("api", "playlist"): + LOG.error("FATAL: BRIDGE_MODE must be 'api' or 'playlist', got %r", MODE) + return 1 + + LOG.info("Navidrome %s -> betoniarka %s (mode=%s)", NAVIDROME_URL, BETONIARKA_URL, MODE) + LOG.info("Path translation: %s -> %s", NAVIDROME_LIBRARY_ROOT, BETONIARKA_LIBRARY_ROOT) + if DRY_RUN: + LOG.info("DRY RUN - nothing will be queued") + + already = load_state() + if not STATE_FILE.exists() and SKIP_EXISTING_ON_FIRST_RUN and not DRY_RUN: + try: + already = {str(song.get("id", "")) for song in fetch_starred()} + save_state(already) + LOG.info("First run: ignoring %d already-starred tracks", len(already)) + except (requests.exceptions.RequestException, RuntimeError) as exc: + LOG.error("Could not read existing stars: %s", exc) + return 1 + + while True: + try: + updated = process_once(set(already)) + if updated != already: + already = updated + save_state(already) + except (requests.exceptions.RequestException, RuntimeError) as exc: + LOG.warning("Poll failed (retrying in %ss): %s", POLL_SECONDS, exc) + time.sleep(POLL_SECONDS) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + LOG.info("Shutdown requested") + sys.exit(0) diff --git a/navidrome_bridge/compose.yaml b/navidrome_bridge/compose.yaml new file mode 100644 index 0000000..72c06b4 --- /dev/null +++ b/navidrome_bridge/compose.yaml @@ -0,0 +1,26 @@ +# Navidrome -> betoniarka request bridge (external component; see README.md). +# Run from THIS directory: +# cp env.example .env # then edit +# docker compose up -d --build +services: + navidrome-bridge: + build: + context: . + image: navidrome-bridge:latest + container_name: navidrome-bridge + restart: unless-stopped + env_file: + - .env + volumes: + # Which starred tracks were already sent to the radio. + - ./data:/data + + # ONLY for BRIDGE_MODE=playlist: the radio's data dir, so the exact path + # can be appended to request.playlist. Leave commented out for the + # default "api" mode, which needs no shared filesystem at all. + # - /srv/betoniarka/data:/srv/betoniarka/data + + # Optional, and only useful in playlist mode: mounting the library here + # (read-only) lets the bridge verify a translated path really exists + # before queueing it, turning a silent radio no-op into a clear error. + # - /srv/betoniarka/music:/srv/betoniarka/music:ro diff --git a/navidrome_bridge/env.example b/navidrome_bridge/env.example new file mode 100644 index 0000000..20efc88 --- /dev/null +++ b/navidrome_bridge/env.example @@ -0,0 +1,48 @@ +# Copy to .env and fill in. Do NOT commit the real file. + +# --- Navidrome (read-only Subsonic API; the password is never sent in clear) -- +NAVIDROME_URL=http://navidrome:4533 +NAVIDROME_USER=your-navidrome-user +NAVIDROME_PASSWORD=your-navidrome-password + +# --- Path translation ---------------------------------------------------- +# The SAME mp3 library, mounted at two different points. Whatever prefix +# Navidrome reports is stripped and replaced with the radio's mount point. +# Check the Navidrome side with: +# docker exec navidrome printenv ND_MUSICFOLDER +# and the radio side with the betoniarka container's BETONIARKA_MUSIC. +NAVIDROME_LIBRARY_ROOT=/music +BETONIARKA_LIBRARY_ROOT=/srv/betoniarka/music + +# --- Radio (betoniarka) -------------------------------------------------- +BETONIARKA_URL=http://radio-vm:5005 +# Same shared secret the radio runs with (empty = the radio has auth disabled). +CONJURER_API_KEY= + +# How the request reaches the radio: +# api - POST /request_radio_file over the network. No shared filesystem. +# The radio re-finds the track from keywords, so it is a fuzzy +# match (accurate in practice - full path tokens are very +# distinctive - but not guaranteed). +# playlist - append the exact translated path to the request playlist. +# Exact, but needs the radio's data dir mounted here. +BRIDGE_MODE=api +BETONIARKA_REQUEST_PLAYLIST=/srv/betoniarka/data/request.playlist + +# --- Behaviour ----------------------------------------------------------- +# How often to look for newly starred tracks. +BRIDGE_POLL_SECONDS=15 +BRIDGE_STATE_FILE=/data/queued.json + +# Un-star a track once it has been queued, turning the heart into a +# fire-and-forget "send to radio" button that resets itself. Leave false if you +# use stars as real favourites. +BRIDGE_UNSTAR_AFTER_QUEUE=false + +# On the very first run, treat everything already starred as handled, so +# enabling the bridge does not dump your whole favourites list into the radio. +BRIDGE_SKIP_EXISTING_ON_FIRST_RUN=true + +# Log what would be sent without sending anything - use this to verify the two +# library roots line up before going live. +BRIDGE_DRY_RUN=false diff --git a/navidrome_bridge/requirements.txt b/navidrome_bridge/requirements.txt new file mode 100644 index 0000000..f229360 --- /dev/null +++ b/navidrome_bridge/requirements.txt @@ -0,0 +1 @@ +requests From 298f4b111ed02dd6b5de2fa4060cbd17459100a3 Mon Sep 17 00:00:00 2001 From: Michal Tuszowski Date: Tue, 21 Jul 2026 19:43:14 +0200 Subject: [PATCH 2/2] 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 --- navidrome_bridge/README.md | 91 +++++++++++++++++++++++++++-------- navidrome_bridge/bridge.py | 80 +++++++++++++++++++++++++----- navidrome_bridge/compose.yaml | 12 +++-- navidrome_bridge/env.example | 24 ++++++--- 4 files changed, 165 insertions(+), 42 deletions(-) diff --git a/navidrome_bridge/README.md b/navidrome_bridge/README.md index 99b0d63..9a9ed75 100644 --- a/navidrome_bridge/README.md +++ b/navidrome_bridge/README.md @@ -14,14 +14,17 @@ request queue. One click, from whatever you already listen with. > * 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). +> * 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 ──(/request_radio_file, or the request playlist)──► betoniarka ──► Liquidsoap +> Navidrome ──(Subsonic API: read stars, optional unstar)──► bridge > -> Kondziu (Discord bot) ....................... not involved +> bridge ──(api mode: /request_radio_file)──► betoniarka ──┐ +> bridge ──(dropbox mode: request.playlist)────────────────┴─► Liquidsoap +> +> Kondziu (Discord bot) ....................... not involved in either > ``` --- @@ -58,8 +61,10 @@ Sources: [Navidrome plugin docs](https://www.navidrome.org/docs/usage/features/p 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. +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. @@ -94,12 +99,13 @@ BRIDGE_DRY_RUN=true docker compose up # logs every translation, sends nothi ## Two delivery modes -| | `api` (default) | `playlist` | +| | `api` (default) | `dropbox` | |---|---|---| -| 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 | +| 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. @@ -109,8 +115,51 @@ 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. +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 @@ -132,8 +181,8 @@ 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`. +For `dropbox` mode, also set `BRIDGE_MODE=dropbox` and uncomment the +betoniarka data volume in `compose.yaml`. ### Without Docker @@ -174,7 +223,9 @@ Every setting, its default, and what it does is documented inline in | `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_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 | @@ -186,8 +237,10 @@ Every setting, its default, and what it does is documented inline in | `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 | +| 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 diff --git a/navidrome_bridge/bridge.py b/navidrome_bridge/bridge.py index cc1dd2c..ece903a 100644 --- a/navidrome_bridge/bridge.py +++ b/navidrome_bridge/bridge.py @@ -56,12 +56,23 @@ BETONIARKA_LIBRARY_ROOT = _env("BETONIARKA_LIBRARY_ROOT", "/srv/betoniarka/music BETONIARKA_URL = _env("BETONIARKA_URL", "http://localhost:5005").rstrip("/") CONJURER_API_KEY = _env("CONJURER_API_KEY") -# "api" - POST /request_radio_file (works over the network; the radio -# re-finds the track by keywords, so it is a fuzzy match) -# "playlist" - append the exact translated path to the request playlist -# (exact, but needs the radio's data dir reachable here) +# "api" - POST /request_radio_file. Works over the network, but betoniarka +# re-finds the track from keywords, so it is a fuzzy match. +# "dropbox" - write the exact path straight into the spool file that +# radio_conjurer.liq drains. Exact, and involves neither betoniarka +# nor the bot: just this file and Liquidsoap. ("playlist" is +# accepted as an alias for this mode.) MODE = _env("BRIDGE_MODE", "api").strip().lower() +if MODE == "playlist": + MODE = "dropbox" + +# The editable drop box itself. Point this at the exact file the liq reads - +# note the name is singular, request.playlist. REQUEST_PLAYLIST = Path(_env("BETONIARKA_REQUEST_PLAYLIST", "/srv/betoniarka/data/request.playlist")) +# Whether to confirm the translated path really exists before queueing it. +# "auto" (default) checks only when the library happens to be mounted here, so +# a drop-box-only setup is not forced to mount the whole music collection. +VERIFY_FILE_EXISTS = _env("BRIDGE_VERIFY_FILE_EXISTS", "auto").strip().lower() POLL_SECONDS = int(_env("BRIDGE_POLL_SECONDS", "15")) STATE_FILE = Path(_env("BRIDGE_STATE_FILE", "/data/queued.json")) @@ -182,19 +193,55 @@ def queue_via_api(radio_path: str) -> None: response.raise_for_status() -def queue_via_playlist(radio_path: str) -> None: - if not Path(radio_path).exists(): +def _should_verify() -> bool: + if VERIFY_FILE_EXISTS in ("1", "true", "yes", "on"): + return True + if VERIFY_FILE_EXISTS in ("0", "false", "no", "off"): + return False + # "auto": only meaningful when the library is actually mounted here. + return Path(BETONIARKA_LIBRARY_ROOT).is_dir() + + +def queue_via_dropbox(radio_path: str) -> None: + """Append the exact path to the spool file Liquidsoap drains. + + radio_conjurer.liq treats this file as a drop box, not as a playlist: + + text = file.lines(".../request.playlist") + list.iter(fun(item) -> requests_queue.push.uri(item), text) + file.remove(...) # then recreated empty + + every 60 seconds. Consequences worth knowing: + + * lines are pushed as URIs, so this is an exact hand-off - no searching, + no chance of the radio picking a different take of the same song; + * pickup takes up to a minute; + * requests_queue sits first in the fallback, so a request interrupts the + normal rotation at the next track boundary; + * the queue is not subject to the check_next replay guard that the regular + playlists use, so a request plays even if the track ran recently; + * the file is emptied under us by design - never treat it as durable state. + """ + if _should_verify() and not Path(radio_path).exists(): raise RuntimeError( f"{radio_path} not visible here - check the two library roots and the mount" ) - REQUEST_PLAYLIST.parent.mkdir(parents=True, exist_ok=True) + # A missing parent almost always means the radio's data dir was not + # mounted; creating it would silently swallow requests into this + # container's own filesystem, so fail loudly instead. + if not REQUEST_PLAYLIST.parent.is_dir(): + raise RuntimeError( + f"{REQUEST_PLAYLIST.parent} does not exist here - is the radio's data dir mounted?" + ) + # One append per request: O_APPEND makes a single small write atomic, so a + # concurrent drain can never see a half-written path. with REQUEST_PLAYLIST.open("a", encoding="utf-8") as handle: handle.write(radio_path + "\n") def queue(radio_path: str) -> None: - if MODE == "playlist": - queue_via_playlist(radio_path) + if MODE == "dropbox": + queue_via_dropbox(radio_path) else: queue_via_api(radio_path) @@ -279,11 +326,20 @@ def main() -> int: if missing: LOG.error("FATAL: missing required config: %s", ", ".join(missing)) return 1 - if MODE not in ("api", "playlist"): - LOG.error("FATAL: BRIDGE_MODE must be 'api' or 'playlist', got %r", MODE) + if MODE not in ("api", "dropbox"): + LOG.error( + "FATAL: BRIDGE_MODE must be 'api' or 'dropbox' ('playlist' = 'dropbox'), got %r", + MODE, + ) return 1 - LOG.info("Navidrome %s -> betoniarka %s (mode=%s)", NAVIDROME_URL, BETONIARKA_URL, MODE) + if MODE == "dropbox": + # Neither betoniarka nor the bot is involved in this mode. + LOG.info("Navidrome %s -> drop box %s (mode=dropbox)", NAVIDROME_URL, REQUEST_PLAYLIST) + LOG.info("Liquidsoap drains that file every 60s; path verification: %s", + "on" if _should_verify() else "off") + else: + LOG.info("Navidrome %s -> betoniarka %s (mode=api)", NAVIDROME_URL, BETONIARKA_URL) LOG.info("Path translation: %s -> %s", NAVIDROME_LIBRARY_ROOT, BETONIARKA_LIBRARY_ROOT) if DRY_RUN: LOG.info("DRY RUN - nothing will be queued") diff --git a/navidrome_bridge/compose.yaml b/navidrome_bridge/compose.yaml index 72c06b4..7bcdc53 100644 --- a/navidrome_bridge/compose.yaml +++ b/navidrome_bridge/compose.yaml @@ -15,12 +15,14 @@ services: # Which starred tracks were already sent to the radio. - ./data:/data - # ONLY for BRIDGE_MODE=playlist: the radio's data dir, so the exact path - # can be appended to request.playlist. Leave commented out for the - # default "api" mode, which needs no shared filesystem at all. + # ONLY for BRIDGE_MODE=dropbox: the radio's data dir, so the exact path + # can be appended to request.playlist - the file radio_conjurer.liq + # drains every 60s. Leave commented out for the default "api" mode, + # which needs no shared filesystem at all. # - /srv/betoniarka/data:/srv/betoniarka/data - # Optional, and only useful in playlist mode: mounting the library here + # Optional, and only useful in dropbox mode: mounting the library here # (read-only) lets the bridge verify a translated path really exists - # before queueing it, turning a silent radio no-op into a clear error. + # before queueing it (BRIDGE_VERIFY_FILE_EXISTS=auto), turning a silent + # radio no-op into a clear error. # - /srv/betoniarka/music:/srv/betoniarka/music:ro diff --git a/navidrome_bridge/env.example b/navidrome_bridge/env.example index 20efc88..8d24ede 100644 --- a/navidrome_bridge/env.example +++ b/navidrome_bridge/env.example @@ -20,15 +20,27 @@ BETONIARKA_URL=http://radio-vm:5005 CONJURER_API_KEY= # How the request reaches the radio: -# api - POST /request_radio_file over the network. No shared filesystem. -# The radio re-finds the track from keywords, so it is a fuzzy -# match (accurate in practice - full path tokens are very -# distinctive - but not guaranteed). -# playlist - append the exact translated path to the request playlist. -# Exact, but needs the radio's data dir mounted here. +# api - POST /request_radio_file over the network. No shared filesystem. +# betoniarka re-finds the track from keywords, so it is a fuzzy +# match (accurate in practice - full path tokens are very +# distinctive - but not guaranteed). +# dropbox - write the exact path straight into the file Liquidsoap drains. +# Exact hand-off, and it involves neither betoniarka nor the bot; +# needs that file reachable here. ("playlist" = same thing.) BRIDGE_MODE=api + +# dropbox mode only: the editable drop box radio_conjurer.liq reads every 60s. +# It pushes each line as a URI and then empties the file. Note the singular +# name - request.playlist, not requests.playlist. BETONIARKA_REQUEST_PLAYLIST=/srv/betoniarka/data/request.playlist +# Confirm the translated path exists before queueing it. +# auto - check only when BETONIARKA_LIBRARY_ROOT is actually mounted here +# (so a drop-box-only setup need not mount the whole library) +# true - always check; refuse to queue what cannot be seen +# false - never check +BRIDGE_VERIFY_FILE_EXISTS=auto + # --- Behaviour ----------------------------------------------------------- # How often to look for newly starred tracks. BRIDGE_POLL_SECONDS=15