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