Fix #766: Navidrome album covers blank in the sync editor (+ other modals)

The sync editor renders server covers as <img src="/api/navidrome/cover/{id}">,
but no Flask route ever served that path — so every Navidrome cover 404'd, on
every album, art or not. The source (left) side then went blank too: a source
row with no native art (e.g. YouTube, which provides none at mirror time) falls
back to borrowing the matched server track's cover — i.e. that same dead route.
So both sides collapsed to nothing.

Fix:
- New NavidromeClient.build_cover_art_url(cover_id) — builds the absolute,
  Subsonic-authenticated getCoverArt URL (base_url + token/salt), keeping
  credentials server-side. Uses a FIXED cover-art salt so the URL is
  deterministic for a given (server, password, cover_id): a rotating salt (as
  in _generate_auth_params) would make every request a unique URL → image-cache
  miss every time + a dead, never-reused cache row per fetch. Token auth doesn't
  require a unique salt, and the password is never exposed (only its salted md5).
- New route /api/navidrome/cover/<cover_id> — resolves that URL and streams the
  image through the shared image cache (same pattern as /api/image-proxy), with
  a private max-age so the browser caches by the stable route URL.

Effect: server side works for any album that has art in Navidrome; matched
source rows with no native art now borrow the (now-working) server cover.
Unmatched YouTube rows stay blank — no image exists anywhere to show.

Tests: tests/test_navidrome_cover_url.py (8) — URL structure + salted-token auth
(never the raw password), determinism (same id -> same URL so the cache hits;
different id/password -> different URL), optional size, and the not-connected /
no-id / no-credentials guards.

Caveats: not executed against a live Navidrome (no server in CI) — the URL
builder is unit-tested; the route's cache→HTTP→bytes round-trip is read-verified
only. Scope is the sync editor's Navidrome route; Plex/Jellyfin server-cover
branches and any other modals using a different mechanism are untouched.
This commit is contained in:
BoulderBadgeDad 2026-06-02 11:01:28 -07:00
parent 3b49ac8280
commit 89b438974f
3 changed files with 147 additions and 0 deletions

View file

@ -3,6 +3,7 @@ import hashlib
import secrets import secrets
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from datetime import datetime from datetime import datetime
from urllib.parse import urlencode
import json import json
from utils.logging_config import get_logger from utils.logging_config import get_logger
from config.settings import config_manager from config.settings import config_manager
@ -316,6 +317,42 @@ class NavidromeClient(MediaServerClient):
'f': 'json' # Response format 'f': 'json' # Response format
} }
# Fixed salt for cover-art URLs ONLY. Subsonic token auth (t=md5(password
# +salt), s=salt) does not require a unique salt per request, and a stable
# one makes the cover URL deterministic — so the image cache and the
# browser cache actually HIT. The rotating salt from _generate_auth_params
# would make every request a unique URL → cache miss every time + a dead,
# never-reused cache row per fetch (#766 review). The password is never
# exposed either way (only its salted md5).
_COVER_ART_SALT = 'soulsync-cover'
def build_cover_art_url(self, cover_id, size=None) -> Optional[str]:
"""Absolute, Subsonic-authenticated getCoverArt URL for ``cover_id``.
Deterministic for a given (server, password, cover_id) so it caches.
The web layer proxies this to the browser (sync editor + modals).
Returns ``None`` when not connected or no id was supplied. #766: the
``/api/navidrome/cover/<id>`` route had no working URL behind it, so
every Navidrome cover came back blank."""
if not self.base_url or not cover_id:
return None
if not self.username or not self.password:
return None
salt = self._COVER_ART_SALT
token = hashlib.md5((self.password + salt).encode()).hexdigest()
params = {
'u': self.username,
't': token,
's': salt,
'v': '1.16.1',
'c': 'SoulSync',
'f': 'json', # harmless for getCoverArt — it returns image binary
'id': str(cover_id),
}
if size:
params['size'] = str(size)
return f"{self.base_url}/rest/getCoverArt?{urlencode(params)}"
# Subsonic endpoints that modify data — use POST to avoid URL length limits # Subsonic endpoints that modify data — use POST to avoid URL length limits
_WRITE_ENDPOINTS = frozenset({ _WRITE_ENDPOINTS = frozenset({
'createPlaylist', 'updatePlaylist', 'deletePlaylist', 'createPlaylist', 'updatePlaylist', 'deletePlaylist',

View file

@ -0,0 +1,83 @@
"""Tests for Navidrome cover-art URL building (#766).
The sync editor + modals referenced /api/navidrome/cover/<id> but no route
served it, and the URL behind it had to be a fully-authenticated Subsonic
getCoverArt URL. build_cover_art_url is that builder these pin its shape and
the not-connected guards (the token/salt are random per call, so we assert
structure + required params rather than 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_cover_url():
url = _connected_client().build_cover_art_url("al-123")
parts = urlsplit(url)
assert parts.scheme == "https"
assert parts.netloc == "nav.example.com"
assert parts.path == "/rest/getCoverArt"
q = parse_qs(parts.query)
assert q["id"] == ["al-123"]
assert q["u"] == ["boulder"]
# Subsonic token auth: salted md5, never the raw password.
assert q["t"] and q["t"][0] != "hunter2"
assert q["s"] # salt present
assert "hunter2" not in url
for required in ("t", "s", "v", "c"):
assert required in q
def test_cover_url_is_deterministic_so_the_cache_hits():
# #766 review: the URL must be stable for a given (server, password,
# cover_id) — otherwise the image cache keys on a rotating salt and misses
# every request, re-fetching Navidrome each time + leaking dead rows.
c = _connected_client()
assert c.build_cover_art_url("al-123") == c.build_cover_art_url("al-123")
# ...and different covers still produce different URLs.
assert c.build_cover_art_url("al-123") != c.build_cover_art_url("al-999")
def test_cover_url_changes_with_password():
# A password change must invalidate the cached URL (new token).
c1 = _connected_client()
c2 = _connected_client()
c2.password = "different"
assert c1.build_cover_art_url("al-1") != c2.build_cover_art_url("al-1")
def test_size_param_optional():
assert "size" not in (_connected_client().build_cover_art_url("x") or "")
assert "size=300" in _connected_client().build_cover_art_url("x", size=300)
def test_cover_id_is_stringified():
url = _connected_client().build_cover_art_url(12345)
assert "id=12345" in url
def test_returns_none_when_not_connected():
c = NavidromeClient() # base_url is None
assert c.build_cover_art_url("al-1") is None
def test_returns_none_for_empty_cover_id():
assert _connected_client().build_cover_art_url("") is None
assert _connected_client().build_cover_art_url(None) is None
def test_returns_none_without_credentials():
c = NavidromeClient()
c.base_url = "https://nav.example.com" # but no username/password
assert c.build_cover_art_url("al-1") is None

View file

@ -4133,6 +4133,33 @@ def select_jellyfin_music_library():
logger.error(f"Error setting Jellyfin music library: {e}") logger.error(f"Error setting Jellyfin music library: {e}")
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/navidrome/cover/<cover_id>', methods=['GET'])
def navidrome_cover(cover_id):
"""Proxy a Navidrome (Subsonic) cover-art image to the browser.
The sync editor and other modals reference /api/navidrome/cover/<id>,
but no route served it so every Navidrome cover came back blank (#766).
We build the authenticated getCoverArt URL server-side (keeping Subsonic
credentials off the client) and stream it through the shared image cache.
"""
try:
client = media_server_engine.client('navidrome')
if not client:
return '', 404
url = client.build_cover_art_url(cover_id)
if not url:
return '', 404
from core.image_cache import get_image_cache
cached = get_image_cache().get_url(url)
response = send_file(cached.path, mimetype=cached.mime_type, conditional=True)
max_age = int(config_manager.get("image_cache.ttl_seconds", 2592000))
response.headers['Cache-Control'] = f'private, max-age={max_age}'
return response
except Exception as exc:
logger.debug("navidrome cover proxy failed for %s: %s", cover_id, exc)
return '', 502
@app.route('/api/navidrome/music-folders', methods=['GET']) @app.route('/api/navidrome/music-folders', methods=['GET'])
def get_navidrome_music_folders(): def get_navidrome_music_folders():
"""Get list of available music folders from Navidrome""" """Get list of available music folders from Navidrome"""