Compare commits

..

2 Commits

Author SHA1 Message Date
Michal Tuszowski 298f4b111e navidrome_bridge: add dropbox mode writing straight to the request spool
Reading radio_conjurer.liq shows request.playlist is not a Liquidsoap playlist
at all - it is a drop box. queue_processing() runs every 60s, reads every line,
pushes each into request.queue() as a URI, then deletes the file and recreates
it empty. That changes what writing to it means, so the mode is now named and
documented for what it is.

BRIDGE_MODE=dropbox (with "playlist" kept as an alias) appends the exact
translated path to that file. Because the lines are pushed as URIs it is an
exact hand-off - no keyword search - and it involves neither betoniarka nor the
bot, just the file and Liquidsoap. The liq also puts requests_queue first in the
fallback and does not apply the check_next replay guard to it, so a request
interrupts the rotation and plays even if the track ran recently; both are now
documented rather than left to be discovered.

Fixes found while wiring this up:
* the existence check was unconditional while the library mount was documented
  as optional, so a drop-box-only setup could never queue anything. It is now
  BRIDGE_VERIFY_FILE_EXISTS (auto|true|false), defaulting to checking only when
  the library is actually visible;
* a missing parent dir was silently created, which would swallow requests into
  the container's own filesystem when the radio's data dir was not mounted. It
  is now a hard error naming the likely cause.

Verified: alias resolves; append/drain/append cycle against a simulation of the
liq's read-remove-recreate; both misconfigurations raise instead of silently
succeeding; drop-box-only path works with the library absent; api mode
unchanged, still prepending the sentinel that wyszukaj() discards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:43:14 +02:00
Michal Tuszowski 7f652aa9db 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 <noreply@anthropic.com>
2026-07-21 19:28:46 +02:00
17 changed files with 758 additions and 438 deletions
+1 -1
View File
@@ -201,4 +201,4 @@ cr_results.json
not_in_db.json
rr_results.json
s_results.json
*.bak
*.bak.DS_Store
+5 -14
View File
@@ -7,13 +7,9 @@ import argparse
import sys
from pathlib import Path
# — CONFIGURATION — env-overridable; defaults = original bare-metal Pi layout
# APACHE_LOG must be the share-specific access log (see docker/share-vhost.conf);
# pointing it at the site-wide log also works but wastes time scanning noise.
APACHE_LOG = os.getenv('CONJURER_APACHE_LOG', '/var/log/apache2/share_access.log')
SHARE_DIR = Path(os.getenv('CONJURER_SHARE_DIR', '/var/www/html/share'))
# Seconds to keep a link alive after its FIRST download (not after creation).
TTL_SECONDS = int(os.getenv('CONJURER_SHARE_TTL_SECONDS', '3600'))
# — CONFIGURATION — adjust as needed
APACHE_LOG = '/var/log/apache2/share_access.log' # <- point to your share-specific log!
SHARE_DIR = Path('/var/www/html/share')
METADATA_FILE = SHARE_DIR / '.downloads.json'
STATE_FILE = SHARE_DIR / '.logpos'
# Regex to match lines like: "GET /share/abcdef1234... HTTP/1.1"
@@ -70,7 +66,7 @@ def record_downloads(lines, metadata, args):
def revoke_old(metadata, args):
now = time.time()
to_remove = [tok for tok, ts in metadata.items() if now - ts >= TTL_SECONDS]
to_remove = [tok for tok, ts in metadata.items() if now - ts >= 3600]
for tok in to_remove:
link = SHARE_DIR / tok
try:
@@ -82,12 +78,7 @@ def revoke_old(metadata, args):
return bool(to_remove)
def main():
p = argparse.ArgumentParser(
description=(
"Revoke share links CONJURER_SHARE_TTL_SECONDS "
f"(currently {TTL_SECONDS}s) after their first download"
)
)
p = argparse.ArgumentParser(description="Revoke share links 2min after download")
p.add_argument('--debug', action='store_true', help='print debug info to stderr')
args = p.parse_args()
+5 -12
View File
@@ -1,18 +1,12 @@
#!/usr/bin/env python3
"""Build the share index that the musician's /get_share_list endpoint searches.
Walks the media root and writes a JSON catalogue of every file/dir found.
Meant to run periodically (cron on bare metal, the scheduler loop in the share
container). Paths are environment-overridable; the defaults reproduce the
original Raspberry Pi layout, so existing cron entries keep working unchanged.
"""
import os
import json
import time
from pathlib import Path
# Configuration (env-overridable; defaults = original bare-metal Pi layout)
SCAN_PATH = os.getenv('CONJURER_SHARE_SCAN_PATH', '/mnt/shares')
OUTPUT_FILE = os.getenv('CONJURER_SHARE_DB', '/var/log/share_scan.json')
# Configuration
SCAN_PATH = '/mnt/shares'
OUTPUT_FILE = '/var/log/share_scan.json'
def scan_directory(root):
"""Recursively walk `root` and collect metadata."""
@@ -40,8 +34,7 @@ def main():
'root': SCAN_PATH,
'entries': scan_directory(SCAN_PATH),
}
# Write atomically (create the target dir first so a fresh volume works)
os.makedirs(os.path.dirname(OUTPUT_FILE) or '.', exist_ok=True)
# Write atomically
temp = OUTPUT_FILE + '.tmp'
with open(temp, 'w') as f:
json.dump(data, f, indent=2)
-36
View File
@@ -1,36 +0,0 @@
# Conjurer share service: Apache publishing short-lived symlink shares, plus
# the two periodic jobs that build the search index and revoke expired links.
#
# This is the piece that used to live only as hand-placed scripts + hand-edited
# Apache config on the host. Everything it needs is now in the image; the state
# it shares with the musician (the link dir and the index) comes in as volumes.
#
# Build from the repository root:
# docker build -f docker/Dockerfile.share -t conjurer-share .
FROM debian:bookworm-slim
# apache2 serves the links; python3 runs the scanner/revoker (both stdlib-only,
# so there is nothing to pip install).
RUN apt-get update && apt-get install -y --no-install-recommends \
apache2 python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Same scripts the bare-metal deployment uses - no forked copies.
COPY conjurer_musician/scan_shares.py ./scan_shares.py
COPY conjurer_musician/revoke_shares.py ./revoke_shares.py
COPY docker/share-vhost.conf.tpl ./share-vhost.conf.tpl
COPY docker/entrypoint.share.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# /mnt/shares - media library (mount read-only; symlink targets)
# /var/www/html/share - the symlinks themselves (SHARED with the musician,
# which is what creates them)
# /srv/share/db - share_scan.json (SHARED with the musician, which
# searches it)
# /var/log/apache2 - access log the revoker tails
VOLUME ["/mnt/shares", "/var/www/html/share", "/srv/share/db", "/var/log/apache2"]
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
-43
View File
@@ -1,43 +0,0 @@
# Share VM/host: Apache publishing short-lived file links + the scan/revoke jobs.
# Run from the repository root:
# cp docker/env/share.env.example docker/env/share.env # then edit
# docker compose -f docker/compose.share.yaml up -d --build
#
# IMPORTANT - this service shares two paths with the musician, which is the
# process that actually creates the links:
#
# /srv/share/links -> the symlinks (musician writes, Apache reads)
# /srv/share/db -> share_scan.json (this service writes, musician reads)
#
# So the musician must mount the same two host paths (see the CONJURER_SHARE_*
# block in docker/env/musician.env.example). If the musician runs on a different
# machine, put both on shared storage - otherwise it will happily create links
# that this container cannot see.
#
# The media library must be mounted here at the SAME absolute path that the
# index recorded, because the symlink targets are absolute. Default: /mnt/shares.
services:
conjurer-share:
build:
context: ..
dockerfile: docker/Dockerfile.share
image: conjurer-share:latest
container_name: conjurer-share
restart: unless-stopped
env_file:
- env/share.env
ports:
# Plain HTTP. Terminate TLS for czernobog.pl on your existing reverse
# proxy and forward /share here.
- "8081:80"
volumes:
# Media library the links point at. Read-only: this service only ever
# serves these files, it never writes them.
- /srv/share/media:/mnt/shares:ro
# The published symlinks - shared with the musician.
- /srv/share/links:/var/www/html/share
# The search index - shared with the musician.
- /srv/share/db:/srv/share/db
# Access log the revoker tails; kept on the host so link history survives
# a container rebuild.
- /srv/share/logs:/var/log/apache2
-67
View File
@@ -1,67 +0,0 @@
#!/bin/sh
# Entrypoint for the Conjurer share container: renders the vhost, seeds the
# share index, then runs the two periodic jobs alongside Apache.
#
# Cron is deliberately not used. Both scripts are already written to be safe to
# re-run (the scanner writes atomically, the revoker persists its log position),
# so plain sleep loops give the same result with one less moving part and with
# their output on the container's stdout where `docker logs` can see it.
set -eu
: "${CONJURER_SHARE_SERVER_NAME:=localhost}"
: "${CONJURER_SHARE_SCAN_PATH:=/mnt/shares}"
: "${CONJURER_SHARE_DB:=/srv/share/db/share_scan.json}"
: "${CONJURER_SHARE_DIR:=/var/www/html/share}"
: "${CONJURER_APACHE_LOG:=/var/log/apache2/share_access.log}"
: "${CONJURER_SHARE_SCAN_INTERVAL:=3600}"
: "${CONJURER_SHARE_REVOKE_INTERVAL:=60}"
: "${CONJURER_SHARE_TTL_SECONDS:=3600}"
export CONJURER_SHARE_SCAN_PATH CONJURER_SHARE_DB CONJURER_SHARE_DIR \
CONJURER_APACHE_LOG CONJURER_SHARE_TTL_SECONDS
# Render the vhost (same placeholder pattern as icecast.xml.tpl).
sed "s|__SERVER_NAME__|${CONJURER_SHARE_SERVER_NAME}|g" \
/app/share-vhost.conf.tpl > /etc/apache2/sites-available/000-default.conf
mkdir -p "$CONJURER_SHARE_DIR" \
"$(dirname "$CONJURER_SHARE_DB")" \
"$(dirname "$CONJURER_APACHE_LOG")" \
/var/run/apache2
chown -R www-data:www-data "$CONJURER_SHARE_DIR"
# revoke_shares.py seeks to a stored byte offset, so the log must exist before
# the first pass or it starts by reporting an error.
touch "$CONJURER_APACHE_LOG"
# Seed the index immediately when absent - otherwise /get_share_list returns
# nothing until the first scheduled scan, which looks like a broken feature.
if [ ! -f "$CONJURER_SHARE_DB" ]; then
echo "[share] no index at $CONJURER_SHARE_DB - running initial scan of $CONJURER_SHARE_SCAN_PATH"
python3 /app/scan_shares.py || echo "[share] initial scan failed, continuing"
fi
# Periodic index rebuild.
(
while true; do
sleep "$CONJURER_SHARE_SCAN_INTERVAL"
echo "[share] rescanning $CONJURER_SHARE_SCAN_PATH"
python3 /app/scan_shares.py || echo "[share] scan failed"
done
) &
SCAN_PID=$!
# Periodic revocation of links whose TTL expired after first download.
(
while true; do
sleep "$CONJURER_SHARE_REVOKE_INTERVAL"
python3 /app/revoke_shares.py || echo "[share] revoke failed"
done
) &
REVOKE_PID=$!
trap 'kill "$SCAN_PID" "$REVOKE_PID" 2>/dev/null || true' INT TERM
echo "[share] serving $CONJURER_SHARE_DIR as ${CONJURER_SHARE_SERVER_NAME}/share"
echo "[share] scan every ${CONJURER_SHARE_SCAN_INTERVAL}s, revoke sweep every ${CONJURER_SHARE_REVOKE_INTERVAL}s, TTL ${CONJURER_SHARE_TTL_SECONDS}s after first download"
exec apache2ctl -D FOREGROUND
-14
View File
@@ -15,17 +15,3 @@ CONJURER_MUSIC_FOLDER=/music
# Runtime paths (mounted volume) — playlists/logs the service writes.
CONJURER_MUSICIAN_BASE=/data
CONJURER_LOGSTORE=/data/logs
# --- File sharing (optional; see docs/deployment/FILE_SHARING.md) --------
# The musician SEARCHES the index and CREATES the symlinks; the share container
# builds the index, serves the links and revokes them. Both must therefore point
# at the same two host paths — mount them into this container as well:
# - /srv/share/db:/srv/share/db
# - /srv/share/links:/var/www/html/share
# Leave these unset to keep the feature off (the endpoints then just find
# nothing).
# CONJURER_SHARE_DB=/srv/share/db/share_scan.json
# CONJURER_SHARE_DIR=/var/www/html/share
# Public URL prefix handed back to Discord; host half must match the share
# container's CONJURER_SHARE_SERVER_NAME.
# CONJURER_SHARE_BASE_URL=https://czernobog.pl/share
-49
View File
@@ -1,49 +0,0 @@
# Apache vhost for the Conjurer file-share links.
#
# The entrypoint substitutes __SERVER_NAME__ from CONJURER_SHARE_SERVER_NAME and
# writes the result to /etc/apache2/sites-available/000-default.conf (same
# pattern as docker/icecast.xml.tpl).
#
# What this serves: media_search_functions.publish() creates a symlink named
# after a random 32-hex token inside the share dir, pointing at a file in the
# media library. Apache hands that file out at /share/<token>; revoke_shares.py
# later unlinks it. Nothing is ever copied.
<VirtualHost *:80>
ServerName __SERVER_NAME__
DocumentRoot /var/www/html
# The share links ARE symlinks into the media library, so FollowSymLinks is
# mandatory. SymLinksIfOwnerMatch is deliberately NOT used: the targets are
# owned by whoever owns the media, not by www-data, so it would break every
# link. -Indexes is equally mandatory - a directory listing here would hand
# out every currently live token at once.
<Directory /var/www/html/share>
Options +FollowSymLinks -Indexes
AllowOverride None
Require all granted
# revoke_shares.py keeps its state (.downloads.json, .logpos) in this
# very directory. .downloads.json maps every live token to its download
# time, so serving it would leak the entire active link set.
<FilesMatch "^\.">
Require all denied
</FilesMatch>
</Directory>
# Nothing outside /share is published. The more specific block above wins
# for the share dir itself.
<Directory /var/www/html>
Options -Indexes -FollowSymLinks
AllowOverride None
Require all denied
</Directory>
# Dedicated access log. revoke_shares.py tails exactly this file and matches
# the standard "%r" request line against:
# "\s*GET\s+/share/([0-9a-f]{32})\s+HTTP/
# so the format must stay `combined` (or any format containing %r).
SetEnvIf Request_URI "^/share/" conjurer_share
CustomLog ${APACHE_LOG_DIR}/share_access.log combined env=conjurer_share
ErrorLog ${APACHE_LOG_DIR}/share_error.log
</VirtualHost>
-5
View File
@@ -7,11 +7,6 @@ Runbook for running Conjurer as Docker containers across Proxmox VMs:
| **Main bot** | VM-bot | `conjurer-bot` | 5000 (Flask comm) | `docker/Dockerfile.bot` |
| **Librarian** | VM-librarian | `conjurer-librarian` | 5001 | `docker/Dockerfile.librarian` |
| **Musician** | VM-musician | `conjurer-musician` | 5000 (+ radio) | `docker/Dockerfile.musician` |
| **Share** (optional) | VM-musician or its own | `conjurer-share` | 8081 → 80 | `docker/Dockerfile.share` |
The share service publishes short-lived file links over Apache and is documented
separately in [FILE_SHARING.md](FILE_SHARING.md) — it shares two volumes with the
musician, so set it up after the musician is running.
The three talk to each other over HTTP on the Proxmox LAN. Direction of calls:
-197
View File
@@ -1,197 +0,0 @@
# File sharing (short-lived share links)
Publishes a file from the media library behind a random, unguessable URL that
stops working shortly after someone downloads it. Driven from Discord.
Historically only the Python half of this lived in the repo; the Apache config
and the cron entries were placed on the host by hand. This document plus
`docker/Dockerfile.share` close that gap.
## What it actually does
1. A **scanner** walks the media library and writes a JSON index of every path.
2. From Discord you search that index and pick files.
3. **Publishing** creates a `symlink` named after a random token
(`uuid.uuid4().hex`, 32 hex chars) inside the share directory, pointing at the
real file. Nothing is copied.
4. **Apache** serves that symlink at `https://<host>/share/<token>`.
5. A **revoker** tails Apache's access log, records when each token was *first*
downloaded, and unlinks it once the TTL expires.
Two things worth being precise about, because the names suggest otherwise:
- The token is **random, not a hash** of the file. Nothing about the content is
recoverable from the URL, and the same file shared twice gets two URLs.
- The "database" is a **JSON file**, not a SQL database.
(`utils/import_to_mysql.py` is an unrelated, unfinished scratch script.)
## The pieces
| Path | Role | Runs in |
|---|---|---|
| `conjurer_musician/scan_shares.py` | Builds the JSON index from the media root | share container (or cron) |
| `conjurer_musician/revoke_shares.py` | Tails the access log, unlinks expired tokens | share container (or cron) |
| `conjurer_musician/media_search_functions.py` | Search + publish helpers used by the service | musician |
| `conjurer_musician.py``/get_share_list`, `/get_share_links` | HTTP API, both behind `X-Conjurer-Api-Key` | musician |
| `file_search_functions.py` | Bot-side HTTP client | bot |
| `file_search_commands.py` | Discord cog: search UI + Accept/Cancel | bot |
| `docker/share-vhost.conf.tpl` | Apache vhost (symlinks, no indexes, dedicated log) | share container |
| `conjurer_musician/search_and_share.py` | Standalone CLI doing steps 13 by hand | anywhere (optional) |
Flow:
```
media library ──► scan_shares.py ──► share_scan.json ──┐
│ (reads)
Discord ──► bot cog ──► musician /get_share_list ─────────┘
└─► musician /get_share_links ──► symlink in share dir
│ (serves)
Apache vhost ─────────────────────┘
│ (logs)
└──► share_access.log ──► revoke_shares.py ──► unlink
```
The share directory and the index are **shared state between the musician and
the share service**: the musician creates links and reads the index, the share
service serves links and writes the index. Both must see the same two paths.
## Enabling it — Docker (recommended)
```bash
cp docker/env/share.env.example docker/env/share.env
# edit: CONJURER_SHARE_SERVER_NAME, and the TTL/intervals if you want
docker compose -f docker/compose.share.yaml up -d --build
```
Host layout the compose file expects:
| Host path | Contents |
|---|---|
| `/srv/share/media` | the media library (mounted read-only) |
| `/srv/share/links` | the published symlinks — **also mount into the musician** |
| `/srv/share/db` | `share_scan.json`**also mount into the musician** |
| `/srv/share/logs` | Apache logs, incl. `share_access.log` |
Then point the musician at the same two paths. In `docker/env/musician.env`:
```ini
CONJURER_SHARE_DB=/srv/share/db/share_scan.json
CONJURER_SHARE_DIR=/var/www/html/share
CONJURER_SHARE_BASE_URL=https://czernobog.pl/share
```
and add the matching mounts to `docker/compose.musician.yaml`:
```yaml
- /srv/share/db:/srv/share/db
- /srv/share/links:/var/www/html/share
```
Finally forward `/share` from your public TLS endpoint to the container's port
(`8081` by default). The container speaks plain HTTP on purpose — TLS for
`czernobog.pl` stays on the reverse proxy you already run.
> **The media library must be mounted at the same absolute path the index
> recorded** (`/mnt/shares` by default). The symlinks are absolute, so if the
> scanner saw `/mnt/shares/x.mkv` and Apache has the library somewhere else, every
> link 404s.
The container does not use cron: the entrypoint runs the scanner and the revoker
in sleep loops next to Apache, so their output lands in `docker logs`.
## Enabling it — existing Apache on the host
If you keep serving from the host's Apache instead:
1. Install the vhost. Take `docker/share-vhost.conf.tpl`, replace
`__SERVER_NAME__`, and drop it into `/etc/apache2/sites-available/`, then
`a2ensite` + `systemctl reload apache2`. The three settings that matter are
explained in the Security section below — do not simplify them away.
2. Schedule both jobs. The scripts default to the original bare-metal paths, so
plain cron entries work with no environment at all:
```cron
*/5 * * * * /usr/bin/python3 /opt/conjurer/conjurer_musician/revoke_shares.py
17 * * * * /usr/bin/python3 /opt/conjurer/conjurer_musician/scan_shares.py
```
Defaults: media root `/mnt/shares`, index `/var/log/share_scan.json`, links
`/var/www/html/share`, log `/var/log/apache2/share_access.log`, TTL 3600s.
Override any of them with the `CONJURER_SHARE_*` variables from
`docker/env/share.env.example`.
3. Point the musician at the same paths via `CONJURER_SHARE_DB`,
`CONJURER_SHARE_DIR` and `CONJURER_SHARE_BASE_URL`.
## Using it
```
$tajna_biblioteka_inkwizycji <1-10> <keywords>
```
Restricted to the **Jarl** and **Thane** roles. It replies with the matches, you
select from the dropdown and press Accept; the bot answers with one URL per file.
(The docstring in the cog still says `!findfiles` — that name is stale.)
## Security
The vhost is not boilerplate; three of its settings are load-bearing:
- **`Options +FollowSymLinks`** — the shares *are* symlinks, so without it every
link 403s. `SymLinksIfOwnerMatch` cannot be substituted: the targets are owned
by whoever owns the media, not by `www-data`.
- **`Options -Indexes`** — a directory listing of the share dir would hand out
every currently live token in one request.
- **Deny dotfiles** — `revoke_shares.py` keeps `.downloads.json` (a map of every
live token to its download time) and `.logpos` *inside the served directory*.
Serving `.downloads.json` would leak the entire active link set.
Also worth knowing:
- Both HTTP endpoints call `_authorize_request()`, so they require
`X-Conjurer-Api-Key` whenever `CONJURER_API_KEY` is set. The bot sends it via
`service_headers()`.
- Anyone with the URL can download the file until it expires — the token is the
only credential.
- Mount the media read-only, as the compose file does. This service never needs
to write to the library.
## Verifying
```bash
# index built?
docker compose -f docker/compose.share.yaml exec conjurer-share \
python3 -c "import json;d=json.load(open('/srv/share/db/share_scan.json'));print(len(d['entries']),'entries')"
# a link resolves? (create one from Discord first)
curl -sI https://czernobog.pl/share/<token> | head -1
# the listing must NOT work, and neither must the state file
curl -sI https://czernobog.pl/share/ | head -1 # expect 403
curl -sI https://czernobog.pl/share/.downloads.json | head -1 # expect 403
# revoker seeing downloads?
docker compose -f docker/compose.share.yaml logs --tail=20 conjurer-share
```
Common failures:
| Symptom | Cause |
|---|---|
| Search finds nothing | index missing/empty — check the scanner ran and `CONJURER_SHARE_SCAN_PATH` is right |
| Every link 404s | media not mounted at the path the index recorded |
| Every link 403s | `FollowSymLinks` missing on the share directory |
| Links never expire | revoker not running, or reading a log the vhost isn't writing |
| Bot gets 401 | `CONJURER_API_KEY` set on the musician but not on the bot |
## Known limitations
- **A link nobody downloads is never revoked.** The TTL starts at the first
download, so unused symlinks accumulate in the share directory forever. Prune
it periodically if that matters.
- **Log rotation confuses the revoker.** It stores a byte offset into the access
log; when the file is rotated the offset points into the new, shorter file and
entries can be skipped. Prefer a long rotation interval, or clear
`.logpos` after rotating.
- **The index is a full rescan**, not incremental — cost grows with library size.
+3
View File
@@ -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/
+22
View File
@@ -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"]
+260
View File
@@ -0,0 +1,260 @@
# 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`).
+373
View File
@@ -0,0 +1,373 @@
#!/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, 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"))
# 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 _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"
)
# 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 == "dropbox":
queue_via_dropbox(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", "dropbox"):
LOG.error(
"FATAL: BRIDGE_MODE must be 'api' or 'dropbox' ('playlist' = 'dropbox'), got %r",
MODE,
)
return 1
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")
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)
+28
View File
@@ -0,0 +1,28 @@
# 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=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 dropbox mode: mounting the library here
# (read-only) lets the bridge verify a translated path really exists
# 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
+60
View File
@@ -0,0 +1,60 @@
# 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.
# 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
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
+1
View File
@@ -0,0 +1 @@
requests