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:
+68
-12
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user