diff --git a/core/wishlist/routes.py b/core/wishlist/routes.py index c9536828..5fe27fd9 100644 --- a/core/wishlist/routes.py +++ b/core/wishlist/routes.py @@ -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 . 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 diff --git a/tests/wishlist/test_image_enrichment.py b/tests/wishlist/test_image_enrichment.py new file mode 100644 index 00000000..3eeaa672 --- /dev/null +++ b/tests/wishlist/test_image_enrichment.py @@ -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)"} diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index 14ced632..7b793e68 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -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);