Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33ec1c0e35 |
+1
-1
@@ -201,4 +201,4 @@ cr_results.json
|
||||
not_in_db.json
|
||||
rr_results.json
|
||||
s_results.json
|
||||
*.bak.DS_Store
|
||||
*.bak
|
||||
@@ -7,9 +7,13 @@ import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# — 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')
|
||||
# — 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'))
|
||||
METADATA_FILE = SHARE_DIR / '.downloads.json'
|
||||
STATE_FILE = SHARE_DIR / '.logpos'
|
||||
# Regex to match lines like: "GET /share/abcdef1234... HTTP/1.1"
|
||||
@@ -66,7 +70,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 >= 3600]
|
||||
to_remove = [tok for tok, ts in metadata.items() if now - ts >= TTL_SECONDS]
|
||||
for tok in to_remove:
|
||||
link = SHARE_DIR / tok
|
||||
try:
|
||||
@@ -78,7 +82,12 @@ def revoke_old(metadata, args):
|
||||
return bool(to_remove)
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Revoke share links 2min after download")
|
||||
p = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Revoke share links CONJURER_SHARE_TTL_SECONDS "
|
||||
f"(currently {TTL_SECONDS}s) after their first download"
|
||||
)
|
||||
)
|
||||
p.add_argument('--debug', action='store_true', help='print debug info to stderr')
|
||||
args = p.parse_args()
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
#!/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
|
||||
SCAN_PATH = '/mnt/shares'
|
||||
OUTPUT_FILE = '/var/log/share_scan.json'
|
||||
# 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')
|
||||
|
||||
def scan_directory(root):
|
||||
"""Recursively walk `root` and collect metadata."""
|
||||
@@ -34,7 +40,8 @@ def main():
|
||||
'root': SCAN_PATH,
|
||||
'entries': scan_directory(SCAN_PATH),
|
||||
}
|
||||
# Write atomically
|
||||
# Write atomically (create the target dir first so a fresh volume works)
|
||||
os.makedirs(os.path.dirname(OUTPUT_FILE) or '.', exist_ok=True)
|
||||
temp = OUTPUT_FILE + '.tmp'
|
||||
with open(temp, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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"]
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/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
|
||||
Vendored
+14
@@ -15,3 +15,17 @@ 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
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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>
|
||||
@@ -7,6 +7,11 @@ 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:
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# 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 1–3 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.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Runtime state written by the bridge (which stars were already queued) and by
|
||||
# the compose file's bind mount. Never belongs in git.
|
||||
data/
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,260 +0,0 @@
|
||||
# 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`).
|
||||
@@ -1,373 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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 +0,0 @@
|
||||
requests
|
||||
Reference in New Issue
Block a user