wishlist: render library-sourced art (album + artist) — fix blank wishlist images
root cause: the library stores album/artist art as media-server RELATIVE paths (Plex
/library/metadata/.., Jellyfin /Items/.., Navidrome /rest/..), which don't render in a browser
<img>. normal wishlist items carry Spotify CDN urls so they show fine, but LIBRARY-sourced
items — dead-file re-downloads and preview-clip re-fetches — carry the raw relative path, so
their album art came up blank. and the nebula only had artist photos for WATCHLISTED artists,
so non-watchlist orbs showed initials.
fix on READ in the wishlist tracks endpoint (so it also repairs items already in the wishlist,
no re-run needed), using the library data we already have:
- normalize each track's album.images url that needs it — relative/internal only, via the
canonical normalize_image_url; CDN urls are left untouched so already-rendering items can't
regress.
- build an artist-name -> normalized library-photo map and return it; the nebula seeds its
artist-image map from it (every wishlist artist), with curated watchlist photos overriding.
8 tests (predicate: relative/internal fixed, CDN untouched; album normalize in-place; artist
map build/skip-empty/idempotent/graceful). 237 wishlist+repair+JS tests green, ruff clean.
This commit is contained in:
parent
4c53a8f3a2
commit
b683c7fb50
3 changed files with 184 additions and 3 deletions
|
|
@ -8,6 +8,8 @@ import threading
|
|||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
from core.metadata import normalize_image_url
|
||||
from core.metadata.artwork import is_internal_image_host
|
||||
from core.wishlist.reporting import build_wishlist_stats_payload
|
||||
from core.wishlist.selection import prepare_wishlist_tracks_for_display
|
||||
from core.wishlist.service import get_wishlist_service
|
||||
|
|
@ -210,6 +212,74 @@ def set_wishlist_cycle(runtime: WishlistRouteRuntime, cycle: str) -> tuple[Dict[
|
|||
return {"error": str(exc)}, 500
|
||||
|
||||
|
||||
def _needs_image_fix(url: str | None) -> bool:
|
||||
"""True when an image URL won't render in the browser as-is — a media-server RELATIVE
|
||||
path (/library/.., /Items/.., /rest/..) or an internal/localhost host. Spotify/iTunes CDN
|
||||
URLs render directly and are left untouched, so already-working items never change."""
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
if url.startswith('/') and not url.startswith('//'):
|
||||
return True
|
||||
if url.startswith('http://') or url.startswith('https://'):
|
||||
return is_internal_image_host(url)
|
||||
return False
|
||||
|
||||
|
||||
def _enrich_wishlist_images(tracks: list[dict[str, Any]], db: Any) -> dict[str, str]:
|
||||
"""Make wishlist art browser-renderable using the library data we already have.
|
||||
|
||||
The library stores album/artist art as media-server RELATIVE paths (e.g. Plex
|
||||
/library/metadata/..) which don't render in a browser <img>. Normal wishlist items carry
|
||||
Spotify CDN URLs (fine), but library-sourced items — re-downloads and preview-clip
|
||||
re-fetches — carry the relative path, so their art comes up blank. We fix two things here,
|
||||
on read, so it also repairs items already sitting in the wishlist:
|
||||
|
||||
1. Normalize each track's album.images[*].url that needs it (relative/internal only —
|
||||
CDN URLs are left as-is to avoid regressing items that already render).
|
||||
2. Build an artist-name -> normalized library photo map so the nebula can show artist
|
||||
photos for non-watchlist artists (it otherwise only has watchlisted-artist photos).
|
||||
"""
|
||||
artist_names: set[str] = set()
|
||||
for track in tracks:
|
||||
sd = track.get('spotify_data')
|
||||
if isinstance(sd, dict):
|
||||
album = sd.get('album')
|
||||
if isinstance(album, dict):
|
||||
images = album.get('images')
|
||||
if isinstance(images, list):
|
||||
for img in images:
|
||||
if isinstance(img, dict) and _needs_image_fix(img.get('url')):
|
||||
fixed = normalize_image_url(img['url'])
|
||||
if fixed:
|
||||
img['url'] = fixed
|
||||
name = track.get('artist_name')
|
||||
if name and name != 'Unknown Artist':
|
||||
artist_names.add(name)
|
||||
|
||||
artist_images: dict[str, str] = {}
|
||||
if not artist_names:
|
||||
return artist_images
|
||||
try:
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
placeholders = ','.join('?' * len(artist_names))
|
||||
rows = conn.execute(
|
||||
f"SELECT name, thumb_url FROM artists "
|
||||
f"WHERE name IN ({placeholders}) AND thumb_url IS NOT NULL AND thumb_url != ''",
|
||||
list(artist_names),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
for row in rows:
|
||||
name, thumb = row[0], row[1]
|
||||
fixed = normalize_image_url(thumb) if _needs_image_fix(thumb) else thumb
|
||||
if name and fixed:
|
||||
artist_images[name.lower()] = fixed
|
||||
except Exception as exc: # noqa: BLE001 — art is cosmetic, never fail the tracks endpoint
|
||||
logger.debug("Could not build wishlist artist-image map: %s", exc)
|
||||
return artist_images
|
||||
|
||||
|
||||
def get_wishlist_tracks(
|
||||
runtime: WishlistRouteRuntime,
|
||||
*,
|
||||
|
|
@ -242,6 +312,9 @@ def get_wishlist_tracks(
|
|||
prepared["duplicates_found"],
|
||||
)
|
||||
|
||||
# Make library-sourced art renderable + supply artist photos (see _enrich_wishlist_images).
|
||||
artist_images = _enrich_wishlist_images(prepared["tracks"], db)
|
||||
|
||||
if category:
|
||||
runtime.logger.info(
|
||||
"Wishlist filter: %s/%s tracks in '%s' category (limit: %s)",
|
||||
|
|
@ -250,9 +323,18 @@ def get_wishlist_tracks(
|
|||
category,
|
||||
limit or "none",
|
||||
)
|
||||
return {"tracks": prepared["tracks"], "category": category, "total": prepared["total"]}, 200
|
||||
return {
|
||||
"tracks": prepared["tracks"],
|
||||
"category": category,
|
||||
"total": prepared["total"],
|
||||
"artist_images": artist_images,
|
||||
}, 200
|
||||
|
||||
return {"tracks": prepared["tracks"], "total": prepared["total"]}, 200
|
||||
return {
|
||||
"tracks": prepared["tracks"],
|
||||
"total": prepared["total"],
|
||||
"artist_images": artist_images,
|
||||
}, 200
|
||||
except Exception as exc:
|
||||
runtime.logger.error("Error getting wishlist tracks: %s", exc)
|
||||
return {"error": str(exc)}, 500
|
||||
|
|
|
|||
91
tests/wishlist/test_image_enrichment.py
Normal file
91
tests/wishlist/test_image_enrichment.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""Wishlist art enrichment (read-path): library-sourced items (re-downloads, preview-clip
|
||||
re-fetches) store media-server RELATIVE thumb paths that don't render in a browser, and the
|
||||
nebula only has artist photos for watchlisted artists. _enrich_wishlist_images fixes both on
|
||||
read — normalizing relative/internal image URLs (leaving CDN URLs untouched) and building an
|
||||
artist-name -> library-photo map — so even items already sitting in the wishlist get fixed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from core.wishlist import routes
|
||||
|
||||
|
||||
class _DB:
|
||||
def __init__(self, artists):
|
||||
self._conn = sqlite3.connect(":memory:")
|
||||
self._conn.execute("CREATE TABLE artists (name TEXT, thumb_url TEXT)")
|
||||
self._conn.executemany("INSERT INTO artists VALUES (?, ?)", artists)
|
||||
self._conn.commit()
|
||||
|
||||
def _get_connection(self):
|
||||
return self._conn
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_normalize(monkeypatch):
|
||||
# Deterministic stand-in for the real Plex/Jellyfin URL rebuild.
|
||||
monkeypatch.setattr(routes, "normalize_image_url", lambda u: f"PROXY({u})")
|
||||
|
||||
|
||||
def test_needs_image_fix_predicate():
|
||||
assert routes._needs_image_fix("/library/metadata/1/thumb/2") is True
|
||||
assert routes._needs_image_fix("/Items/x/Images/Primary") is True
|
||||
assert routes._needs_image_fix("http://localhost:32400/library/x") is True
|
||||
assert routes._needs_image_fix("https://i.scdn.co/image/ab") is False
|
||||
assert routes._needs_image_fix("https://is1.mzstatic.com/600x600bb.jpg") is False
|
||||
assert routes._needs_image_fix("") is False
|
||||
assert routes._needs_image_fix(None) is False
|
||||
|
||||
|
||||
def test_relative_album_image_is_normalized():
|
||||
tracks = [{"artist_name": "A",
|
||||
"spotify_data": {"album": {"images": [{"url": "/library/metadata/9/thumb/1"}]}}}]
|
||||
routes._enrich_wishlist_images(tracks, _DB([]))
|
||||
assert tracks[0]["spotify_data"]["album"]["images"][0]["url"] == "PROXY(/library/metadata/9/thumb/1)"
|
||||
|
||||
|
||||
def test_cdn_album_image_is_left_untouched():
|
||||
"""Items that already render must not change — guards against regressing normal wishlist art."""
|
||||
url = "https://i.scdn.co/image/ab67616d"
|
||||
tracks = [{"artist_name": "A", "spotify_data": {"album": {"images": [{"url": url}]}}}]
|
||||
routes._enrich_wishlist_images(tracks, _DB([]))
|
||||
assert tracks[0]["spotify_data"]["album"]["images"][0]["url"] == url
|
||||
|
||||
|
||||
def test_builds_artist_image_map_from_library():
|
||||
tracks = [
|
||||
{"artist_name": "Modest Mouse", "spotify_data": {"album": {"images": []}}},
|
||||
{"artist_name": "Unknown Artist", "spotify_data": {}}, # skipped
|
||||
]
|
||||
db = _DB([("Modest Mouse", "/library/metadata/111/thumb/9"),
|
||||
("Other Band", "/library/metadata/222/thumb/9")]) # not in wishlist → not returned
|
||||
amap = routes._enrich_wishlist_images(tracks, db)
|
||||
assert amap == {"modest mouse": "PROXY(/library/metadata/111/thumb/9)"}
|
||||
|
||||
|
||||
def test_artist_with_empty_thumb_is_omitted():
|
||||
tracks = [{"artist_name": "NoArt", "spotify_data": {"album": {"images": []}}}]
|
||||
amap = routes._enrich_wishlist_images(tracks, _DB([("NoArt", "")]))
|
||||
assert amap == {}
|
||||
|
||||
|
||||
def test_already_proxied_artist_thumb_not_double_wrapped():
|
||||
"""A library thumb that's already a CDN/proxy URL passes through unchanged (idempotent)."""
|
||||
tracks = [{"artist_name": "B", "spotify_data": {"album": {"images": []}}}]
|
||||
amap = routes._enrich_wishlist_images(tracks, _DB([("B", "https://i.scdn.co/image/cdn")]))
|
||||
assert amap == {"b": "https://i.scdn.co/image/cdn"}
|
||||
|
||||
|
||||
def test_handles_string_or_missing_spotify_data_gracefully():
|
||||
tracks = [
|
||||
{"artist_name": "A", "spotify_data": "not-a-dict"},
|
||||
{"artist_name": "B"},
|
||||
{"spotify_data": {"album": {"images": [{"url": "/library/x"}]}}}, # no artist_name
|
||||
]
|
||||
amap = routes._enrich_wishlist_images(tracks, _DB([("A", "/library/a")]))
|
||||
# third track's relative album image still gets fixed
|
||||
assert tracks[2]["spotify_data"]["album"]["images"][0]["url"] == "PROXY(/library/x)"
|
||||
assert amap == {"a": "PROXY(/library/a)"}
|
||||
|
|
@ -1405,8 +1405,16 @@ async function initializeWishlistPage() {
|
|||
fetch('/api/watchlist/artists').then(r => r.json()).catch(() => ({ success: false })),
|
||||
]);
|
||||
|
||||
// Build artist name → image URL map from watchlist
|
||||
// Build artist name → image URL map. Library photos (from the wishlist endpoint, covering
|
||||
// every wishlist artist) seed it first; curated watchlist photos override where present.
|
||||
const _artistImageMap = new Map();
|
||||
for (const res of [albumRes, singleRes]) {
|
||||
if (res && res.artist_images) {
|
||||
for (const [name, url] of Object.entries(res.artist_images)) {
|
||||
if (name && url) _artistImageMap.set(name.toLowerCase(), url);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (watchlistRes.success && watchlistRes.artists) {
|
||||
for (const wa of watchlistRes.artists) {
|
||||
if (wa.artist_name && wa.image_url) _artistImageMap.set(wa.artist_name.toLowerCase(), wa.image_url);
|
||||
|
|
|
|||
Loading…
Reference in a new issue