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>
This commit is contained in:
Michal Tuszowski
2026-07-21 19:43:14 +02:00
parent 7f652aa9db
commit 298f4b111e
4 changed files with 165 additions and 42 deletions
+68 -12
View File
@@ -56,12 +56,23 @@ BETONIARKA_LIBRARY_ROOT = _env("BETONIARKA_LIBRARY_ROOT", "/srv/betoniarka/music
BETONIARKA_URL = _env("BETONIARKA_URL", "http://localhost:5005").rstrip("/")
CONJURER_API_KEY = _env("CONJURER_API_KEY")
# "api" - POST /request_radio_file (works over the network; the radio
# re-finds the track by keywords, so it is a fuzzy match)
# "playlist" - append the exact translated path to the request playlist
# (exact, but needs the radio's data dir reachable here)
# "api" - POST /request_radio_file. Works over the network, but betoniarka
# re-finds the track from keywords, so it is a fuzzy match.
# "dropbox" - write the exact path straight into the spool file that
# radio_conjurer.liq drains. Exact, and involves neither betoniarka
# nor the bot: just this file and Liquidsoap. ("playlist" is
# accepted as an alias for this mode.)
MODE = _env("BRIDGE_MODE", "api").strip().lower()
if MODE == "playlist":
MODE = "dropbox"
# The editable drop box itself. Point this at the exact file the liq reads -
# note the name is singular, request.playlist.
REQUEST_PLAYLIST = Path(_env("BETONIARKA_REQUEST_PLAYLIST", "/srv/betoniarka/data/request.playlist"))
# Whether to confirm the translated path really exists before queueing it.
# "auto" (default) checks only when the library happens to be mounted here, so
# a drop-box-only setup is not forced to mount the whole music collection.
VERIFY_FILE_EXISTS = _env("BRIDGE_VERIFY_FILE_EXISTS", "auto").strip().lower()
POLL_SECONDS = int(_env("BRIDGE_POLL_SECONDS", "15"))
STATE_FILE = Path(_env("BRIDGE_STATE_FILE", "/data/queued.json"))
@@ -182,19 +193,55 @@ def queue_via_api(radio_path: str) -> None:
response.raise_for_status()
def queue_via_playlist(radio_path: str) -> None:
if not Path(radio_path).exists():
def _should_verify() -> bool:
if VERIFY_FILE_EXISTS in ("1", "true", "yes", "on"):
return True
if VERIFY_FILE_EXISTS in ("0", "false", "no", "off"):
return False
# "auto": only meaningful when the library is actually mounted here.
return Path(BETONIARKA_LIBRARY_ROOT).is_dir()
def queue_via_dropbox(radio_path: str) -> None:
"""Append the exact path to the spool file Liquidsoap drains.
radio_conjurer.liq treats this file as a drop box, not as a playlist:
text = file.lines(".../request.playlist")
list.iter(fun(item) -> requests_queue.push.uri(item), text)
file.remove(...) # then recreated empty
every 60 seconds. Consequences worth knowing:
* lines are pushed as URIs, so this is an exact hand-off - no searching,
no chance of the radio picking a different take of the same song;
* pickup takes up to a minute;
* requests_queue sits first in the fallback, so a request interrupts the
normal rotation at the next track boundary;
* the queue is not subject to the check_next replay guard that the regular
playlists use, so a request plays even if the track ran recently;
* the file is emptied under us by design - never treat it as durable state.
"""
if _should_verify() and not Path(radio_path).exists():
raise RuntimeError(
f"{radio_path} not visible here - check the two library roots and the mount"
)
REQUEST_PLAYLIST.parent.mkdir(parents=True, exist_ok=True)
# A missing parent almost always means the radio's data dir was not
# mounted; creating it would silently swallow requests into this
# container's own filesystem, so fail loudly instead.
if not REQUEST_PLAYLIST.parent.is_dir():
raise RuntimeError(
f"{REQUEST_PLAYLIST.parent} does not exist here - is the radio's data dir mounted?"
)
# One append per request: O_APPEND makes a single small write atomic, so a
# concurrent drain can never see a half-written path.
with REQUEST_PLAYLIST.open("a", encoding="utf-8") as handle:
handle.write(radio_path + "\n")
def queue(radio_path: str) -> None:
if MODE == "playlist":
queue_via_playlist(radio_path)
if MODE == "dropbox":
queue_via_dropbox(radio_path)
else:
queue_via_api(radio_path)
@@ -279,11 +326,20 @@ def main() -> int:
if missing:
LOG.error("FATAL: missing required config: %s", ", ".join(missing))
return 1
if MODE not in ("api", "playlist"):
LOG.error("FATAL: BRIDGE_MODE must be 'api' or 'playlist', got %r", MODE)
if MODE not in ("api", "dropbox"):
LOG.error(
"FATAL: BRIDGE_MODE must be 'api' or 'dropbox' ('playlist' = 'dropbox'), got %r",
MODE,
)
return 1
LOG.info("Navidrome %s -> betoniarka %s (mode=%s)", NAVIDROME_URL, BETONIARKA_URL, MODE)
if MODE == "dropbox":
# Neither betoniarka nor the bot is involved in this mode.
LOG.info("Navidrome %s -> drop box %s (mode=dropbox)", NAVIDROME_URL, REQUEST_PLAYLIST)
LOG.info("Liquidsoap drains that file every 60s; path verification: %s",
"on" if _should_verify() else "off")
else:
LOG.info("Navidrome %s -> betoniarka %s (mode=api)", NAVIDROME_URL, BETONIARKA_URL)
LOG.info("Path translation: %s -> %s", NAVIDROME_LIBRARY_ROOT, BETONIARKA_LIBRARY_ROOT)
if DRY_RUN:
LOG.info("DRY RUN - nothing will be queued")