#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:
parent
7207ec61fb
commit
df929dc022
7 changed files with 274 additions and 8 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
74
tests/test_library_stream_fallback.py
Normal file
74
tests/test_library_stream_fallback.py
Normal 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
|
||||
67
tests/test_navidrome_stream_url.py
Normal file
67
tests/test_navidrome_stream_url.py
Normal 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
|
||||
102
web_server.py
102
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue