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.
83 lines
2.9 KiB
Python
83 lines
2.9 KiB
Python
"""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
|