soulsync/tests/test_navidrome_stream_url.py
BoulderBadgeDad df929dc022 #809 Navidrome playback: stream via the server's API when the library isn't mounted on disk
mlody95pl: Navidrome sync works, playback fails ("Failed to resume playback").
Root cause: SoulSync plays library tracks by reading the file off its OWN
disk (/api/library/play → resolve path → serve bytes). "Report Real Path"
gives the correct path STRING, but that's Navidrome's container path — the
files still have to be mounted into the SoulSync container to open them, and
the user's compose has /music commented out. So disk resolution 404s.

Navidrome is a streaming server, so requiring a disk mirror to play from it is
the real limitation. Now, when a library file isn't on SoulSync's disk and the
active server is Navidrome, playback streams through the server's own Subsonic
/rest/stream API — no mount needed:

- NavidromeClient.build_stream_url(song_id, max_bitrate) — token-authed
  /rest/stream URL (mirrors build_cover_art_url; password never exposed).
- /api/library/play: on disk-miss, _build_library_stream_url (Navidrome-only;
  uses the song id sent by the player, or a DB lookup by file_path) sets a
  session stream_url instead of failing.
- /stream/audio: proxies that stream_url with Range passthrough so HTML5
  seeking works, streaming upstream bytes through in 64KB chunks (no full-file
  buffering).
- session state gains stream_url; the two library-play callers now send the
  track's server id.

Disk playback is unchanged (file_path path still wins when the file resolves),
so Plex/Jellyfin and mounted-Navidrome setups behave exactly as before.

Tests: 7 on the URL builder (auth shape, no-transcode default, maxBitRate,
guards) + 4 on the play-fallback routing (navidrome-only, passed-id vs
DB-lookup, none). 200 navidrome/stream/media-server tests pass.
2026-06-07 13:52:14 -07:00

67 lines
2.1 KiB
Python

"""Navidrome stream-URL building (#809): play a library track via the server's
Subsonic /rest/stream API so playback works without mounting the music into
the SoulSync container.
Mirrors the cover-art URL tests — token/salt are random per call, so we assert
structure + required params, not an exact string.
"""
from __future__ import annotations
from urllib.parse import parse_qs, urlsplit
from core.navidrome_client import NavidromeClient
def _connected_client():
c = NavidromeClient()
c.base_url = "https://nav.example.com"
c.username = "boulder"
c.password = "hunter2"
return c
def test_builds_authenticated_stream_url():
url = _connected_client().build_stream_url("song-42")
parts = urlsplit(url)
assert parts.scheme == "https"
assert parts.netloc == "nav.example.com"
assert parts.path == "/rest/stream"
q = parse_qs(parts.query)
assert q["id"] == ["song-42"]
assert q["u"] == ["boulder"]
# Subsonic token auth — salted md5, never the raw password.
assert q["t"] and q["t"][0] != "hunter2"
assert q["s"]
assert "hunter2" not in url
for required in ("t", "s", "v", "c"):
assert required in q
def test_no_transcode_by_default():
assert "maxBitRate" not in (_connected_client().build_stream_url("x") or "")
def test_max_bitrate_when_set():
assert "maxBitRate=320" in _connected_client().build_stream_url("x", max_bitrate=320)
# 0 / falsy → omitted (original file).
assert "maxBitRate" not in _connected_client().build_stream_url("x", max_bitrate=0)
def test_song_id_stringified():
assert "id=12345" in _connected_client().build_stream_url(12345)
def test_returns_none_when_not_connected():
assert NavidromeClient().build_stream_url("song-1") is None
def test_returns_none_for_empty_song_id():
assert _connected_client().build_stream_url("") is None
assert _connected_client().build_stream_url(None) is None
def test_returns_none_without_credentials():
c = NavidromeClient()
c.base_url = "https://nav.example.com"
assert c.build_stream_url("song-1") is None