#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.
This commit is contained in:
BoulderBadgeDad 2026-06-07 13:52:14 -07:00
parent 7207ec61fb
commit df929dc022
7 changed files with 274 additions and 8 deletions

View file

@ -386,6 +386,30 @@ class NavidromeClient(MediaServerClient):
params['size'] = str(size)
return f"{self.base_url}/rest/getCoverArt?{urlencode(params)}"
def build_stream_url(self, track_id, max_bitrate=0) -> Optional[str]:
"""Absolute, Subsonic-authenticated ``/rest/stream`` URL for a song.
Lets SoulSync play a Navidrome library track by proxying the server's
own stream API so playback works WITHOUT mounting the music into the
SoulSync container (#809: SoulSync otherwise reads library files off
disk, which fails when the user hasn't mirror-mounted the library).
``max_bitrate`` 0 = no transcode (original file). Returns None when not
connected / no id."""
if not self.base_url or not track_id:
return None
if not self.username or not self.password:
return None
salt = secrets.token_hex(8)
token = hashlib.md5((self.password + salt).encode()).hexdigest()
params = {
'u': self.username, 't': token, 's': salt,
'v': '1.16.1', 'c': 'SoulSync',
'id': str(track_id),
}
if max_bitrate and int(max_bitrate) > 0:
params['maxBitRate'] = str(int(max_bitrate))
return f"{self.base_url}/rest/stream?{urlencode(params)}"
# Subsonic endpoints that modify data — use POST to avoid URL length limits
_WRITE_ENDPOINTS = frozenset({
'createPlaylist', 'updatePlaylist', 'deletePlaylist',

View file

@ -38,6 +38,10 @@ def _fresh_state() -> Dict[str, Any]:
"progress": 0,
"track_info": None,
"file_path": None,
# Set instead of file_path when a library track is played by proxying
# the media server's own stream API (Navidrome/Subsonic, #809) rather
# than reading the file off disk. /stream/audio proxies this URL.
"stream_url": None,
"error_message": None,
}

View file

@ -0,0 +1,74 @@
"""#809: when a library file isn't on SoulSync's disk, play it by proxying the
media server's stream API instead of 404-ing.
Tests the routing helper _build_library_stream_url: Navidrome-only, uses the
passed song id or falls back to a DB lookup, returns None otherwise.
"""
from __future__ import annotations
import pytest
web_server = pytest.importorskip("web_server")
class _Client:
def build_stream_url(self, song_id, max_bitrate=0):
return f"http://nav.example/rest/stream?id={song_id}"
@pytest.fixture()
def navidrome(monkeypatch):
monkeypatch.setattr(web_server.config_manager, "get_active_media_server", lambda: "navidrome")
monkeypatch.setattr(web_server.media_server_engine, "client", lambda name: _Client())
def test_non_navidrome_server_returns_none(monkeypatch):
monkeypatch.setattr(web_server.config_manager, "get_active_media_server", lambda: "plex")
assert web_server._build_library_stream_url("song1", "/music/x.flac") is None
def test_uses_passed_track_id(navidrome):
url = web_server._build_library_stream_url("song-42", "/music/x.flac")
assert url == "http://nav.example/rest/stream?id=song-42"
def test_falls_back_to_db_lookup_when_no_id(navidrome, monkeypatch):
class _Cur:
def execute(self, *a):
return self
def fetchone(self):
return ("song-from-db",)
class _Conn:
def cursor(self):
return _Cur()
def __enter__(self):
return self
def __exit__(self, *a):
return False
monkeypatch.setattr(web_server, "get_database",
lambda: type("DB", (), {"_get_connection": lambda self: _Conn()})())
url = web_server._build_library_stream_url(None, "/music/x.flac")
assert url == "http://nav.example/rest/stream?id=song-from-db"
def test_no_id_no_db_match_returns_none(navidrome, monkeypatch):
class _Cur:
def execute(self, *a):
return self
def fetchone(self):
return None
class _Conn:
def cursor(self):
return _Cur()
def __enter__(self):
return self
def __exit__(self, *a):
return False
monkeypatch.setattr(web_server, "get_database",
lambda: type("DB", (), {"_get_connection": lambda self: _Conn()})())
assert web_server._build_library_stream_url(None, "/music/x.flac") is None

View file

@ -0,0 +1,67 @@
"""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

View file

@ -11063,6 +11063,43 @@ def _resolve_library_file_path(file_path):
return None
def _build_library_stream_url(track_id, file_path):
"""Build a media-server stream URL for a library track that isn't on the
SoulSync filesystem (#809 — play via the server's API, no disk mount).
Navidrome/Subsonic only for now (it has a clean token-authed stream
endpoint). ``track_id`` is the server's song id (the tracks-table id for a
Navidrome row); if missing, look it up by file_path. Returns None when the
active server isn't Navidrome or no id resolves."""
try:
if config_manager.get_active_media_server() != 'navidrome':
return None
client = media_server_engine.client('navidrome')
if not client:
return None
song_id = str(track_id) if track_id else None
if not song_id and file_path:
# Fall back to a DB lookup by stored path (handles callers that
# didn't pass the id).
try:
db = get_database()
with db._get_connection() as conn:
row = conn.cursor().execute(
"SELECT id FROM tracks WHERE file_path = ? AND server_source = 'navidrome' LIMIT 1",
(file_path,)).fetchone()
if row:
song_id = str(row[0])
except Exception as e:
logger.debug("navidrome stream id lookup failed: %s", e)
if not song_id:
return None
return client.build_stream_url(song_id)
except Exception as e:
logger.debug("build library stream url failed: %s", e)
return None
def _get_file_not_found_error(file_path):
"""Return a helpful error message when a library file can't be found."""
active_server = config_manager.get_active_media_server()
@ -11091,25 +11128,37 @@ def library_play_track():
# Resolve server-side paths (e.g. /mnt/musicBackup/...) to local transfer path
resolved = _resolve_library_file_path(file_path)
stream_url = None
if resolved:
file_path = resolved
else:
return jsonify({"success": False, "error": _get_file_not_found_error(file_path)}), 404
# Not on disk. For a streaming server (Navidrome/Subsonic) we can
# play it through the server's own stream API instead of requiring
# the library to be mounted into the SoulSync container (#809).
stream_url = _build_library_stream_url(data.get('track_id'), file_path)
if not stream_url:
return jsonify({"success": False, "error": _get_file_not_found_error(file_path)}), 404
logger.info(f"Library play request: {os.path.basename(file_path)}")
if stream_url:
logger.info("Library play request (server stream): %s",
data.get('title') or os.path.basename(file_path or ''))
else:
logger.info(f"Library play request: {os.path.basename(file_path)}")
# Set THIS listener's stream state to ready with the library file path.
# Set THIS listener's stream state to ready. Either a local file_path
# (served from disk) or a stream_url (proxied from the media server).
sess = _current_stream_state()
with sess.lock:
sess.update({
"status": "ready",
"progress": 100,
"track_info": {
"title": data.get('title', os.path.basename(file_path)),
"title": data.get('title', os.path.basename(file_path or '')),
"artist": data.get('artist', 'Unknown Artist'),
"album": data.get('album', 'Unknown Album'),
},
"file_path": file_path,
"file_path": None if stream_url else file_path,
"stream_url": stream_url,
"error_message": None,
"is_library": True
})
@ -12359,6 +12408,39 @@ _AUDIO_MIME_TYPES = {
}
def _proxy_stream_url_with_range(stream_url):
"""Proxy a media-server stream URL to the browser, forwarding the Range
header so HTML5 seeking works (#809 — Navidrome/Subsonic playback without a
disk mount). Streams the upstream response body through without buffering
the whole file."""
upstream_headers = {}
range_header = request.headers.get('Range')
if range_header:
upstream_headers['Range'] = range_header
try:
upstream = requests.get(stream_url, headers=upstream_headers, stream=True, timeout=30)
except Exception as e:
logger.error(f"Stream proxy upstream error: {e}")
return jsonify({"error": "Upstream stream failed"}), 502
# Pass through the bytes + the headers a media player needs for seeking.
passthrough = {}
for h in ('Content-Type', 'Content-Length', 'Content-Range', 'Accept-Ranges'):
if h in upstream.headers:
passthrough[h] = upstream.headers[h]
passthrough.setdefault('Accept-Ranges', 'bytes')
def _gen():
try:
for chunk in upstream.iter_content(chunk_size=64 * 1024):
if chunk:
yield chunk
finally:
upstream.close()
return Response(_gen(), status=upstream.status_code, headers=passthrough)
def _serve_audio_file_with_range(file_path, mimetype_override=None):
"""Serve an on-disk audio file with HTTP range support (HTML5 seeking).
@ -12430,9 +12512,17 @@ def stream_audio():
try:
sess = _current_stream_state()
with sess.lock:
if sess["status"] != "ready" or not sess["file_path"]:
if sess["status"] != "ready":
return jsonify({"error": "No audio file ready for streaming"}), 404
file_path = sess["file_path"]
stream_url = sess.get("stream_url")
if not file_path and not stream_url:
return jsonify({"error": "No audio file ready for streaming"}), 404
# Library track played via the media server's stream API (#809).
if stream_url:
logger.info("Serving audio via server stream proxy")
return _proxy_stream_url_with_range(stream_url)
logger.info(f"Serving audio file: {os.path.basename(file_path)}")
return _serve_audio_file_with_range(file_path)

View file

@ -8267,7 +8267,10 @@ async function playLibraryTrack(track, albumTitle, artistName) {
file_path: track.file_path,
title: track.title || '',
artist: artistName || '',
album: albumTitle || ''
album: albumTitle || '',
// Server song id so playback can stream via the media server
// when the file isn't on SoulSync's disk (#809).
track_id: track.id || null
})
});

View file

@ -2244,7 +2244,11 @@ async function playQueueItem(index) {
file_path: track.file_path,
title: track.title || '',
artist: track.artist || '',
album: track.album || ''
album: track.album || '',
// Server song id (Navidrome/Subsonic) so playback can fall
// back to streaming via the server when the file isn't on
// SoulSync's disk (#809).
track_id: track.id || null
})
});
const result = await response.json();