From df929dc02284646aa6976903e5f7832c44611ecc Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 7 Jun 2026 13:52:14 -0700 Subject: [PATCH] #809 Navidrome playback: stream via the server's API when the library isn't mounted on disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/navidrome_client.py | 24 ++++++ core/streaming/state.py | 4 + tests/test_library_stream_fallback.py | 74 +++++++++++++++++++ tests/test_navidrome_stream_url.py | 67 +++++++++++++++++ web_server.py | 102 ++++++++++++++++++++++++-- webui/static/library.js | 5 +- webui/static/media-player.js | 6 +- 7 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 tests/test_library_stream_fallback.py create mode 100644 tests/test_navidrome_stream_url.py diff --git a/core/navidrome_client.py b/core/navidrome_client.py index 0374c4b7..085a4257 100644 --- a/core/navidrome_client.py +++ b/core/navidrome_client.py @@ -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', diff --git a/core/streaming/state.py b/core/streaming/state.py index a35c7531..f8dfd839 100644 --- a/core/streaming/state.py +++ b/core/streaming/state.py @@ -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, } diff --git a/tests/test_library_stream_fallback.py b/tests/test_library_stream_fallback.py new file mode 100644 index 00000000..8d65940c --- /dev/null +++ b/tests/test_library_stream_fallback.py @@ -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 diff --git a/tests/test_navidrome_stream_url.py b/tests/test_navidrome_stream_url.py new file mode 100644 index 00000000..f3be56f7 --- /dev/null +++ b/tests/test_navidrome_stream_url.py @@ -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 diff --git a/web_server.py b/web_server.py index d9296048..8c9c18aa 100644 --- a/web_server.py +++ b/web_server.py @@ -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) diff --git a/webui/static/library.js b/webui/static/library.js index 922cb82e..77e6d2ad 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -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 }) }); diff --git a/webui/static/media-player.js b/webui/static/media-player.js index 7da43aa8..4dff3642 100644 --- a/webui/static/media-player.js +++ b/webui/static/media-player.js @@ -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();