From b950d4704b048e8b6bdabfcc78d27c237388d26e Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:04:21 +0200 Subject: [PATCH] feat(cover): artist artwork from fanart.tv (off by default) (#1137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cover): add artist_artwork_lookup table + accessors Image-scraper P0 (design-review §12): additive library-SQLite migration 013 plus get/upsert/clear-per-server accessors for the external artist-artwork lookup (fanart.tv). Render never reads it; the on-demand cover ensure path and the mbid_ambiguous 24h negative cache use it. server_id = serverIndexKey. * feat(cover): add fanart.tv + getArtistInfo2 provider layer Image-scraper P0 (§7/§19/§23): new cover_cache/external.rs — Rust-side getArtistInfo2 tag-MBID resolution plus fanart.tv v3/music URL + first artistbackground fetch (BYOK client_key sent in addition to the project api_key per fanart.tv ToS, §22). Extract a shared build_subsonic_url helper in fetch.rs (cover URL behaviour unchanged). Add a dedicated low-concurrency fanart_http_sem so external HTTP never starves Navidrome (§26). URL builders unit-tested; wired into ensure_inner next. * feat(cover): wire fanart.tv external branch into ensure_inner Image-scraper P0 (§16): on-demand artist `fanart` ensures try fanart.tv before the Navidrome fallback. MBID resolved Rust-side via getArtistInfo2 (§23, tag MBID); on a miss it falls through WITHOUT a .fetch-failed marker so Navidrome stays the display fallback (§28). External tiers are written as {tier}-fanart.webp in the same entity dir (same cacheKind, §16) — 2000 + 512 (matryoshka §17); peek prefers them for the fanart surface. Dedicated low-concurrency fanart lane (§26); .miss-fanart ~30min negative marker. Additive IPC args (externalArtworkEnabled, surfaceKind), off by default and gated by PSYSONIC_FANART_KEY — inert until a render surface opts in (P1). Quality gate (§11), name->MusicBrainz (§19), and lookup-table writes are P1. * feat(cover): fanart-first peek for the fanart surface Image-scraper P0: for an artist `fanart` ensure, the early peek serves only the external {tier}-fanart.webp tiers; if none exist yet it returns None so ensure runs the external branch (fetch fanart) instead of short-circuiting on a cached Navidrome tier. Realises "fanart prioritised" (§18) for the opt-in surface; Navidrome stays the fallback inside the branch's miss path. * chore(cover): dev-only artist-fanart spike helper DEV-only window.psyFanartSpike(name) — resolves an artist by name and fires the real cover_cache_ensure with externalArtworkEnabled+surfaceKind=fanart to verify the P0 pipeline against a live server (with PSYSONIC_FANART_KEY set). Not wired in production. * feat(cover): §11 quality gate for the fanart surface Before an external fanart fetch, check whether a Navidrome tier already on disk is an HQ ~16:9 image (width >= 1280, aspect 1.6-2.0) and skip the fetch if so — square artist portraits never satisfy it, so the common case still fetches. Reads tier dimensions only (no full decode). Rust consts per the design review. Unit-tested predicate. * feat(cover): persist fanart resolution in artist_artwork_lookup (§12) Wire the lookup table as both the MBID resolution cache and the negative cache: a cached MBID skips the getArtistInfo2 round-trip; no_mbid/mbid_ambiguous back off 24h and miss 30min from updated_at before re-querying. Writes hit/miss/no_mbid with mbid/mbid_source/provider; transient network errors are not cached. All store reads/writes run off the async executor via spawn_blocking and no-op before login. Store reached via app.try_state::(). * feat(cover): compile-time fanart key fallback + album/name IPC args A runtime PSYSONIC_FANART_KEY still wins (dev), else the key baked in at build time via option_env! (release). Add additive artistName/albumTitle ensure args as context for the §19 name->MusicBrainz fallback (inert until the render passes them). Library backfill passes None. * feat(settings): add External Artwork Scraper toggle under Integrations Master toggle (themeStore, off by default per §20) in a new Integrations subsection, alongside the other opt-in third-party categories. Contacts fanart.tv only when enabled. i18n across all 9 locales. * feat(cover): wire fanart background into the fullscreen player (§28) New useArtistFanart hook resolves a fanart.tv 16:9 background via a dedicated cover_cache_ensure (surfaceKind=fanart) — it bypasses the shared peek/disk-src cache (the {tier}-fanart.webp surface is keyed differently) and reuses coverDiskUrl for the asset URL. Fullscreen background priority is now fanart -> Navidrome artist image (cover pipeline) -> album cover; the live useFsArtistPortrait probe is deleted (§28). Additive ensure opts (surfaceKind/artistName/albumTitle); externalArtworkEnabled is derived in ensureArgsFromRef from the master toggle and restricted to the artist fanart surface, so plain cover ensures are unaffected. * feat(cover): generalize external surface to fanart + banner (§13) surfaceKind='banner' fetches the fanart.tv musicbanner array -> {tier}-banner.webp in the same entity dir; fanart stays the 16:9 artistbackground. The ensure branch, peek, lookup rows (per-surface surface_kind), miss marker (.miss-) and tier suffix are all surface-parameterised. The §11 quality gate stays fanart-only (the banner strip has its own aspect). Unit test for the surface->fanart JSON key map. * feat(artist): fanart banner on the artist-detail header (§13, Option B) The artist-detail header gets an album-detail-style background layer: fanart.tv banner (musicbanner) -> the 16:9 fanart background cropped to the strip -> empty. Both via a shared useArtistExternalImage hook (useArtistBanner / useArtistFanart); each fetches on demand and shares the Rust cache, so the header and the fullscreen player warm each other's images. The header is its own stacking context (isolation) so a z-index:-1 banner clips behind the avatar + meta with no content wrapper; the album-style framing (padding/radius/clip) is applied only when a banner is shown, so the off-by-default case stays pixel-identical. * refactor(artist): reuse album-detail header structure for the fanart banner The artist-detail header now uses the same album-detail-* container classes as AlbumHeader (header/bg/overlay/content/hero) with the fanart banner as the background; the Back button moves inside the header. A surgical `artist-detail-bleed` cancels the artist page's .content-body padding so the banner is full-bleed to the container edges, matching the album header exactly instead of the earlier inset card. Reverts the experimental artist-specific bg CSS. * feat(cover): drop artist_artwork_lookup rows on clear-cover-cache (§12/B.4) cover_cache_clear_server already removed the server's whole cover dir (so the {tier}-fanart.webp / -banner.webp tiers + .miss-* markers go with it); also clear the artist_artwork_lookup rows for that server (off-thread) so no stale resolution state lingers. Automatic toggle-off purge deferred — turning the toggle off already hides external artwork (render is gated), and explicit cache-clear now cleans external state too. * feat(cover): name->MusicBrainz album-confirmed MBID resolution (§19) When getArtistInfo2 has no tag MBID and the ensure carries the artist name + an album in context (fullscreen), one MusicBrainz release-search query resolves the artist MBID: the primary artist across score>=90 releases wins, conflicting ids -> mbid_ambiguous (24h backoff), none -> no_mbid. Sends the required User-Agent; a single-permit musicbrainz_sem + >=1s spacing holds us under MB's rate limit. mbid_source=musicbrainz persisted. Banner surface (no album context) correctly skips this. Pure classify/escape helpers unit-tested. * fix(cover): enable banner surface in ensureArgsFromRef externalEnsureFields only set externalArtworkEnabled for surfaceKind 'fanart', so the 'banner' surface never fired — the artist-detail header always fell back to the fanart image instead of the fanart.tv musicbanner. Both external artist surfaces (fanart/banner) now enable the external branch. * feat(settings): optional BYOK personal fanart key field Add an optional personal fanart.tv API key field to the External Artwork Scraper block (shown when the toggle is on): a masked input, a saved/in-use status line, and the simple note that it is sent in addition to the app key. Persisted in themeStore and plumbed through cover_cache_ensure (externalArtworkByok); Rust prefers the settings key, falling back to the PSYSONIC_FANART_CLIENT_KEY dev env. i18n x9. * fix(cover): resolve artist-page fanart image collision on navigation The artist-detail header keyed its fanart/banner hooks on the route `id`, which flips immediately on navigation while `artist`/`albums` refetch a beat later. The mismatched ensure wrote the previous artist's image under the new artist's id (e.g. Sepultura's image under Lordi's id). - key on the loaded `artist.id`, not the route `id`, so id/name/album always describe the same artist - pick the §19 album context from an album that actually belongs to this artist (`albums.find(a => a.artistId === artist.id)`), so a stale album can't run a mismatched name→MusicBrainz query or cache a wrong `no_mbid` - reset `src` on every input change in `useArtistExternalImage` so a previous artist's image never lingers while the new one resolves * fix(cover): strip trailing album qualifier before MusicBrainz lookup Library titles like "Show No Mercy (2004 Remastered)" or "Album [Deluxe Edition]" failed the §19 MusicBrainz release query, blocking name-confirmed MBID resolution. `normalize_album_for_mb` strips a single trailing parenthetical/bracketed qualifier; leading qualifiers (e.g. "(What's the Story) Morning Glory?") are left intact. Unit-tested. * fix(cover): don't cache no_mbid when album context is unavailable The banner ensure could fire before the artist's albums loaded, with no album in context. The old code cached `no_mbid` there and the 24h backoff then blocked the later ensure that arrived *with* album context. Could-not-attempt is not tried-and-failed: the no-album branch now returns without persisting. * fix(cover): don't emit tier-ready for external fanart/banner surfaces `try_external_fanart` emitted `cover:tier-ready` with the `{tier}-{surface}.webp` path. That event is keyed by the canonical cover key (cacheKind/cacheEntityId/ tier, no surface), so the frontend `useCoverArtBridge` listener seeded the Navidrome artist cover's disk-src cache with the external image — leaking fanart/banner into the plain artist cover (avatar, fullscreen "navidrome-artist" fallback) even with the scraper toggled off. Remove the emit: the fanart/banner hooks read the path from the `cover_cache_ensure` return value, so no event is needed. (No disk-level overwrite — the suffixed files are never matched by `tier_exists`; this was frontend disk-src-cache cross-contamination.) * fix(cover): wait for the final external background before showing it, with fade-in The fullscreen player and artist-detail header flashed several backgrounds in sequence while the fanart resolved (upscaled album cover → Navidrome artist image → fanart), and the artist header could show the fanart first and then swap to the banner. - the album cover is no longer a background source — it only feeds the foreground thumbnail - the external-artwork hooks return `{ src, pending }` so callers can tell "still resolving" (hold back) from "resolved, no image" (fall back now) - fullscreen background: scraper on → fanart, empty while it resolves, Navidrome artist image only on a confirmed miss; scraper off → Navidrome artist image - artist header: the banner is preferred — nothing shows while it resolves (no fanart flash), fanart is the fallback only once the banner misses - both backgrounds preload the chosen image and fade it in (`onLoad` plus a `ref` `complete` check so an already-cached image, whose load event can fire before React attaches the handler, still appears). The header fade is a scoped inline opacity so the shared `album-detail-bg` class is untouched. * ci(release): pass PSYSONIC_FANART_KEY into the macOS + Linux builds * refactor(cover): extract external-artwork ensure into its own module Pure code move: the on-demand fanart/banner fetch, the quality gate, the surface-aware peek and the lookup-table cache move from cover_cache/mod.rs into cover_cache/external_ensure.rs. Behaviour unchanged; mod.rs 1877 -> 1488. * chore(cover): remove dev-only fanart spike helper The real render wiring now exercises the external ensure branch, so the dev-only window.psyFanartSpike helper is redundant. * feat(cover): purge external artwork on opt-out (B3) New cover_cache_purge_external command: when the External Artwork toggle is turned off, drop every fetched {tier}-{provider}.webp, .miss-{provider} marker and artist_artwork_lookup row across all configured servers, leaving the canonical Navidrome covers intact. Opting out now removes the third-party-sourced data instead of just hiding it (design-review §9/§12/B.4). * docs: changelog, credits and what's new for artist fanart (PR #1137) --- .../workflows/reusable-channel-publish.yml | 2 + CHANGELOG.md | 6 + WHATS_NEW.md | 4 + .../migrations/013_artist_artwork_lookup.sql | 16 + .../psysonic-library/src/artist_artwork.rs | 146 +++++++ src-tauri/crates/psysonic-library/src/lib.rs | 1 + .../crates/psysonic-library/src/store.rs | 12 +- src-tauri/src/cover_cache/backfill_worker.rs | 6 + src-tauri/src/cover_cache/disk.rs | 16 + src-tauri/src/cover_cache/external.rs | 332 ++++++++++++++ src-tauri/src/cover_cache/external_ensure.rs | 408 ++++++++++++++++++ src-tauri/src/cover_cache/fetch.rs | 37 +- src-tauri/src/cover_cache/mod.rs | 171 +++++++- src-tauri/src/lib.rs | 1 + src/api/coverCache.ts | 54 ++- .../artistDetail/ArtistDetailHero.tsx | 98 ++++- .../FullscreenPlayerStatic.tsx | 69 ++- src/components/settings/IntegrationsTab.tsx | 61 ++- src/config/settingsCredits.ts | 1 + src/cover/useArtistFanart.ts | 78 ++++ src/hooks/useFsArtistPortrait.ts | 34 -- src/locales/de/settings.ts | 6 + src/locales/en/settings.ts | 6 + src/locales/es/settings.ts | 6 + src/locales/fr/settings.ts | 6 + src/locales/nb/settings.ts | 6 + src/locales/nl/settings.ts | 6 + src/locales/ro/settings.ts | 6 + src/locales/ru/settings.ts | 6 + src/locales/zh/settings.ts | 6 + src/store/themeStore.ts | 10 + .../components/fullscreen-player-static.css | 10 + src/styles/components/playlist-edit-modal.css | 9 + 33 files changed, 1563 insertions(+), 73 deletions(-) create mode 100644 src-tauri/crates/psysonic-library/migrations/013_artist_artwork_lookup.sql create mode 100644 src-tauri/crates/psysonic-library/src/artist_artwork.rs create mode 100644 src-tauri/src/cover_cache/external.rs create mode 100644 src-tauri/src/cover_cache/external_ensure.rs create mode 100644 src/cover/useArtistFanart.ts delete mode 100644 src/hooks/useFsArtistPortrait.ts diff --git a/.github/workflows/reusable-channel-publish.yml b/.github/workflows/reusable-channel-publish.yml index 21c340f6..31c19b54 100644 --- a/.github/workflows/reusable-channel-publish.yml +++ b/.github/workflows/reusable-channel-publish.yml @@ -306,6 +306,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }} VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }} + PSYSONIC_FANART_KEY: ${{ secrets.PSYSONIC_FANART_KEY }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} @@ -399,6 +400,7 @@ jobs: env: VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }} VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }} + PSYSONIC_FANART_KEY: ${{ secrets.PSYSONIC_FANART_KEY }} APPIMAGE_EXTRACT_AND_RUN: 1 run: npm run tauri:build -- --bundles deb,rpm,appimage - name: upload Linux artifacts diff --git a/CHANGELOG.md b/CHANGELOG.md index 98896878..4f65b134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Full Japanese (日本語) UI translation — selectable from the language picker on the Settings and Login screens. +### Artist artwork from fanart.tv + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1137](https://github.com/Psychotoxical/psysonic/pull/1137)** + +* New opt-in **External Artwork Scraper** (Settings → Integrations, off by default): artist imagery from fanart.tv — a 16:9 background on the fullscreen player and a wide banner on the artist page — with Navidrome staying the canonical cover. Optional personal key; turning it off removes the fetched images again. + ## Changed diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 24d1e0d1..f0cac9be 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -34,6 +34,10 @@ Within each section, order by **user impact** (most noticeable first) — not PR - Psysonic is now available in **Japanese (日本語)** — pick it from the language menu on the **Settings** and **Login** screens. +### Artist artwork — richer artist and fullscreen views + +- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player and a banner across the top of the artist page. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again. + ## Fixed ### Playback and audio diff --git a/src-tauri/crates/psysonic-library/migrations/013_artist_artwork_lookup.sql b/src-tauri/crates/psysonic-library/migrations/013_artist_artwork_lookup.sql new file mode 100644 index 00000000..827af498 --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/013_artist_artwork_lookup.sql @@ -0,0 +1,16 @@ +-- External artist artwork lookup (fanart.tv etc.) — image-scraper design-review §12. +-- Render NEVER reads this table; only the on-demand cover ensure path + the +-- negative cache (`mbid_ambiguous` 24h backoff) use it. `server_id` is the +-- serverIndexKey (same key as coverStorageKey / the on-disk cover path, §27), +-- NOT the auth-profile UUID. +CREATE TABLE IF NOT EXISTS artist_artwork_lookup ( + server_id TEXT NOT NULL, + artist_id TEXT NOT NULL, + surface_kind TEXT NOT NULL, -- 'fanart' (| 'thumb' later) + mbid TEXT, -- nullable; from tag or MusicBrainz + mbid_source TEXT, -- 'tag' | 'musicbrainz' | NULL + status TEXT NOT NULL, -- pending|hit|miss|skipped|no_mbid|mbid_ambiguous|error + provider TEXT, -- hit source (e.g. 'fanart'); NULL for miss/skipped + updated_at INTEGER NOT NULL, -- unix ms + PRIMARY KEY (server_id, artist_id, surface_kind) +); diff --git a/src-tauri/crates/psysonic-library/src/artist_artwork.rs b/src-tauri/crates/psysonic-library/src/artist_artwork.rs new file mode 100644 index 00000000..f4faa733 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/artist_artwork.rs @@ -0,0 +1,146 @@ +//! External artist artwork lookup table accessors (fanart.tv etc.) — +//! image-scraper design-review §12. Render NEVER reads this; only the +//! on-demand cover ensure path + the `mbid_ambiguous` 24h negative cache use +//! it. `server_id` is the serverIndexKey (§27), not the auth-profile UUID. + +use rusqlite::OptionalExtension; + +use crate::store::LibraryStore; + +/// One `artist_artwork_lookup` row. The `(server_id, artist_id, surface_kind)` +/// primary key is implied by the lookup; this carries the resolution state. +#[derive(Debug, Clone)] +pub struct ArtistArtworkRow { + pub mbid: Option, + pub mbid_source: Option, + pub status: String, + pub provider: Option, + pub updated_at: i64, +} + +/// Fetch the cached lookup row for `(server_id, artist_id, surface_kind)`, if any. +pub fn get_artist_artwork( + store: &LibraryStore, + server_id: &str, + artist_id: &str, + surface_kind: &str, +) -> Result, String> { + store.with_read_conn(|conn| { + conn.query_row( + "SELECT mbid, mbid_source, status, provider, updated_at + FROM artist_artwork_lookup + WHERE server_id = ?1 AND artist_id = ?2 AND surface_kind = ?3", + rusqlite::params![server_id, artist_id, surface_kind], + |row| { + Ok(ArtistArtworkRow { + mbid: row.get(0)?, + mbid_source: row.get(1)?, + status: row.get(2)?, + provider: row.get(3)?, + updated_at: row.get(4)?, + }) + }, + ) + .optional() + }) +} + +/// Insert or replace the lookup row for `(server_id, artist_id, surface_kind)`. +#[allow(clippy::too_many_arguments)] +pub fn upsert_artist_artwork( + store: &LibraryStore, + server_id: &str, + artist_id: &str, + surface_kind: &str, + mbid: Option<&str>, + mbid_source: Option<&str>, + status: &str, + provider: Option<&str>, + updated_at: i64, +) -> Result<(), String> { + store.with_conn_mut("artist_artwork.upsert", |conn| { + conn.execute( + "INSERT INTO artist_artwork_lookup + (server_id, artist_id, surface_kind, mbid, mbid_source, status, provider, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(server_id, artist_id, surface_kind) DO UPDATE SET + mbid = excluded.mbid, + mbid_source = excluded.mbid_source, + status = excluded.status, + provider = excluded.provider, + updated_at = excluded.updated_at", + rusqlite::params![ + server_id, + artist_id, + surface_kind, + mbid, + mbid_source, + status, + provider, + updated_at + ], + )?; + Ok(()) + }) +} + +/// Delete all lookup rows for a server — part of clear-cover-cache-per-server +/// (§12 / Appendix B.4). +pub fn clear_artist_artwork_for_server( + store: &LibraryStore, + server_id: &str, +) -> Result { + store.with_conn_mut("artist_artwork.clear_server", |conn| { + conn.execute( + "DELETE FROM artist_artwork_lookup WHERE server_id = ?1", + rusqlite::params![server_id], + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::LibraryStore; + + #[test] + fn upsert_then_get_roundtrips_and_replaces() { + let store = LibraryStore::open_in_memory(); + let sk = "fanart"; + + assert!(get_artist_artwork(&store, "srv", "ar-1", sk).unwrap().is_none()); + + upsert_artist_artwork( + &store, "srv", "ar-1", sk, None, None, "no_mbid", None, 1000, + ) + .unwrap(); + let row = get_artist_artwork(&store, "srv", "ar-1", sk).unwrap().unwrap(); + assert_eq!(row.status, "no_mbid"); + assert_eq!(row.mbid, None); + assert_eq!(row.updated_at, 1000); + + // Replace (e.g. tag MBID appeared, fanart hit). + upsert_artist_artwork( + &store, + "srv", + "ar-1", + sk, + Some("mbid-123"), + Some("tag"), + "hit", + Some("fanart"), + 2000, + ) + .unwrap(); + let row = get_artist_artwork(&store, "srv", "ar-1", sk).unwrap().unwrap(); + assert_eq!(row.status, "hit"); + assert_eq!(row.mbid.as_deref(), Some("mbid-123")); + assert_eq!(row.mbid_source.as_deref(), Some("tag")); + assert_eq!(row.provider.as_deref(), Some("fanart")); + assert_eq!(row.updated_at, 2000); + + // Clear-per-server removes it. + assert_eq!(clear_artist_artwork_for_server(&store, "srv").unwrap(), 1); + assert!(get_artist_artwork(&store, "srv", "ar-1", sk).unwrap().is_none()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/lib.rs b/src-tauri/crates/psysonic-library/src/lib.rs index 3f04a177..228d9a19 100644 --- a/src-tauri/crates/psysonic-library/src/lib.rs +++ b/src-tauri/crates/psysonic-library/src/lib.rs @@ -15,6 +15,7 @@ mod advanced_search_mood; pub mod analysis_backfill; pub mod analysis_backfill_policy; pub mod library_readiness; +pub mod artist_artwork; pub mod artist_lossless_browse; pub mod cover_backfill; pub mod cover_resolve; diff --git a/src-tauri/crates/psysonic-library/src/store.rs b/src-tauri/crates/psysonic-library/src/store.rs index aef88aab..62ffaf7f 100644 --- a/src-tauri/crates/psysonic-library/src/store.rs +++ b/src-tauri/crates/psysonic-library/src/store.rs @@ -9,7 +9,7 @@ use tauri::Manager; /// Current head of the embedded migrations. Bump each time a new /// `migrations/NNN_*.sql` is added. -pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 12; +pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 13; /// Lowest applied schema version the current code can advance from purely /// additively. If a DB carries a version below this, the breaking-bump hook @@ -26,10 +26,18 @@ pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql /// still pick up `track_genre` + `library_data_migration`. pub(crate) const MIGRATION_012_TRACK_GENRE_LEGACY: &str = include_str!("../migrations/012_track_genre_legacy_repair.sql"); +/// Version 13: additive `artist_artwork_lookup` table for external artist +/// artwork (fanart.tv) — image-scraper §12. Pure CREATE TABLE IF NOT EXISTS. +pub(crate) const MIGRATION_013_ARTIST_ARTWORK_LOOKUP: &str = + include_str!("../migrations/013_artist_artwork_lookup.sql"); /// Embedded migrations. Ordered ascending by `version`; the runner sorts /// defensively before applying so the source order can stay readable. -const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL), (12, MIGRATION_012_TRACK_GENRE_LEGACY)]; +const MIGRATIONS: &[(i64, &str)] = &[ + (1, INITIAL_SQL), + (12, MIGRATION_012_TRACK_GENRE_LEGACY), + (13, MIGRATION_013_ARTIST_ARTWORK_LOOKUP), +]; /// Idempotent repair — also runs after the migration runner on every open so /// DBs that recorded the wrong version numbers still get the tables. diff --git a/src-tauri/src/cover_cache/backfill_worker.rs b/src-tauri/src/cover_cache/backfill_worker.rs index b3d1cb4e..749fa60c 100644 --- a/src-tauri/src/cover_cache/backfill_worker.rs +++ b/src-tauri/src/cover_cache/backfill_worker.rs @@ -309,6 +309,12 @@ async fn ensure_one( password: session.password, library_bulk: true, library_server_id: Some(session.library_server_id), + // Library backfill never touches external providers (§15). + external_artwork_enabled: false, + surface_kind: None, + artist_name: None, + album_title: None, + external_artwork_byok: None, }; let _ = CoverCacheState::ensure_inner(&st, &app, &args, Some(http_sem)).await; } diff --git a/src-tauri/src/cover_cache/disk.rs b/src-tauri/src/cover_cache/disk.rs index ac31712d..b149c238 100644 --- a/src-tauri/src/cover_cache/disk.rs +++ b/src-tauri/src/cover_cache/disk.rs @@ -13,6 +13,22 @@ pub fn tier_path(dir: &Path, tier: u32) -> PathBuf { dir.join(format!("{tier}.webp")) } +/// External-provider tier file in the SAME entity dir, differentiated by a +/// filename suffix only (image-scraper §14/§16): `{tier}-{provider}.webp` +/// (e.g. `2000-fanart.webp`). The `coverStorageKey`/`cacheKind` is unchanged. +pub fn provider_tier_path(dir: &Path, tier: u32, provider: &str) -> PathBuf { + dir.join(format!("{tier}-{provider}.webp")) +} + +pub fn provider_tier_exists(dir: &Path, tier: u32, provider: &str) -> Option { + let p = provider_tier_path(dir, tier, provider); + if p.is_file() { + Some(p) + } else { + None + } +} + #[allow(dead_code)] pub fn meta_path(dir: &Path) -> PathBuf { dir.join("meta.json") diff --git a/src-tauri/src/cover_cache/external.rs b/src-tauri/src/cover_cache/external.rs new file mode 100644 index 00000000..2f8202b3 --- /dev/null +++ b/src-tauri/src/cover_cache/external.rs @@ -0,0 +1,332 @@ +//! External artist-artwork providers (image-scraper P0 spike). +//! +//! - Subsonic `getArtistInfo2` → the artist's tag MusicBrainz id (§19 step 2; +//! MBID resolution stays Rust-side per §23). +//! - fanart.tv `v3/music/` → the first `artistbackground` URL. +//! +//! Mirrors the token auth of `fetch.rs`. The chosen background image's bytes +//! are downloaded by the ensure flow via `fetch::fetch_cover_bytes` (a generic +//! retrying GET). All network use is gated by the caller (feature flag + +//! reachability + the dedicated low-concurrency fanart semaphore). + +use reqwest::Client; + +use super::fetch::build_subsonic_url; + +const FANART_API_BASE: &str = "https://webservice.fanart.tv/v3/music"; +const MUSICBRAINZ_BASE: &str = "https://musicbrainz.org/ws/2"; +/// MusicBrainz requires a meaningful, contactable User-Agent (their ToS). +const MUSICBRAINZ_USER_AGENT: &str = concat!( + "Psysonic/", + env!("CARGO_PKG_VERSION"), + " ( https://github.com/Psychotoxical/psysonic )" +); + +/// Subsonic `getArtistInfo2.view` (JSON) URL for an artist id. +pub fn build_artist_info2_url( + rest_base: &str, + username: &str, + password: &str, + artist_id: &str, +) -> String { + build_subsonic_url( + rest_base, + "getArtistInfo2", + username, + password, + &[("id", artist_id), ("f", "json")], + ) +} + +/// fanart.tv music endpoint URL for an MBID. The BYOK personal `client_key` is +/// sent **in addition to** the project `api_key` when non-empty (fanart.tv ToS, +/// §22) — never a replacement. +pub fn build_fanart_url(mbid: &str, api_key: &str, client_key: Option<&str>) -> String { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("api_key", api_key); + if let Some(ck) = client_key { + if !ck.is_empty() { + serializer.append_pair("client_key", ck); + } + } + format!("{FANART_API_BASE}/{mbid}?{}", serializer.finish()) +} + +/// GET `getArtistInfo2` and extract `artistInfo2.musicBrainzId` (tag MBID). +/// `Ok(None)` when the artist carries no MBID tag. +pub async fn fetch_artist_tag_mbid( + client: &Client, + rest_base: &str, + username: &str, + password: &str, + artist_id: &str, +) -> Result, String> { + let url = build_artist_info2_url(rest_base, username, password, artist_id); + let body = http_get_text(client, &url).await?; + let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?; + let mbid = v + .get("subsonic-response") + .and_then(|r| r.get("artistInfo2")) + .and_then(|a| a.get("musicBrainzId")) + .and_then(|m| m.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + Ok(mbid) +} + +/// Map a render surface to its fanart.tv JSON array key. `fanart` (the 16:9 +/// fullscreen background) → `artistbackground`; `banner` (the wide artist-detail +/// header strip) → `musicbanner`. +pub fn fanart_json_key(surface: &str) -> &'static str { + match surface { + "banner" => "musicbanner", + _ => "artistbackground", + } +} + +/// GET the fanart.tv music JSON for an MBID and return the first image URL for +/// the requested `surface` (the API returns each kind most-liked first). +/// `Ok(None)` when the artist has no image of that kind (404 or empty array). +pub async fn fetch_fanart_image_url( + client: &Client, + mbid: &str, + api_key: &str, + client_key: Option<&str>, + surface: &str, +) -> Result, String> { + let url = build_fanart_url(mbid, api_key, client_key); + let Some(body) = http_get_text_opt(client, &url).await? else { + return Ok(None); // 404 → artist has no fanart at all + }; + let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?; + let img = v + .get(fanart_json_key(surface)) + .and_then(|a| a.as_array()) + .and_then(|arr| arr.first()) + .and_then(|o| o.get("url")) + .and_then(|u| u.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string); + Ok(img) +} + +/// Outcome of a name→MusicBrainz artist-MBID resolution (§19). +pub enum MbResolution { + /// A single, confident artist MBID (one artist across high-score releases). + Found(String), + /// Multiple candidate artists — never guess; the caller backs off 24h. + Ambiguous, + /// No matching release at all. + None, +} + +/// Escape the Lucene special characters that would break a MusicBrainz query. +fn mb_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +/// Strip a single trailing parenthetical / bracketed qualifier (e.g. +/// "(2004 Remastered)", "[Deluxe Edition]") so a decorated library album title +/// still matches the canonical MusicBrainz release. Leaves leading qualifiers +/// (e.g. "(What's the Story) Morning Glory?") untouched. +fn normalize_album_for_mb(title: &str) -> String { + let t = title.trim(); + let stripped = if t.ends_with(')') { + t.rfind(" (").map(|i| &t[..i]).unwrap_or(t) + } else if t.ends_with(']') { + t.rfind(" [").map(|i| &t[..i]).unwrap_or(t) + } else { + t + }; + stripped.trim().to_string() +} + +/// Resolve an artist MBID by name, confirmed by an album release (§19). One +/// query to the MusicBrainz release search; the primary artist across the +/// high-confidence releases wins, conflicting ids → `Ambiguous`. Sends the +/// required User-Agent. The caller enforces the ≤1 req/s rate limit. +pub async fn resolve_mbid_via_musicbrainz( + client: &Client, + artist_name: &str, + album_title: &str, +) -> Result { + let album = normalize_album_for_mb(album_title); + let query = format!( + "artist:\"{}\" AND release:\"{}\"", + mb_escape(artist_name), + mb_escape(&album) + ); + // Scope the (non-Send) serializer so it is dropped before the await below. + let url = { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("query", &query); + serializer.append_pair("fmt", "json"); + serializer.append_pair("limit", "8"); + format!("{MUSICBRAINZ_BASE}/release/?{}", serializer.finish()) + }; + + let resp = client + .get(&url) + .header(reqwest::header::USER_AGENT, MUSICBRAINZ_USER_AGENT) + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + let body = resp.text().await.map_err(|e| e.to_string())?; + let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?; + Ok(classify_mb_releases(&v)) +} + +/// Pure classification of a MusicBrainz release-search response: the primary +/// artist id of each release scoring ≥ 90. One distinct id → `Found`, several → +/// `Ambiguous`, none → `None`. +fn classify_mb_releases(v: &serde_json::Value) -> MbResolution { + let mut ids = std::collections::BTreeSet::new(); + if let Some(releases) = v.get("releases").and_then(|r| r.as_array()) { + for rel in releases { + let score = rel.get("score").and_then(|s| s.as_i64()).unwrap_or(0); + if score < 90 { + continue; + } + if let Some(id) = rel + .get("artist-credit") + .and_then(|c| c.as_array()) + .and_then(|a| a.first()) + .and_then(|c| c.get("artist")) + .and_then(|a| a.get("id")) + .and_then(|i| i.as_str()) + { + ids.insert(id.to_string()); + } + } + } + match ids.len() { + 0 => MbResolution::None, + 1 => MbResolution::Found(ids.into_iter().next().unwrap_or_default()), + _ => MbResolution::Ambiguous, + } +} + +/// Single GET → response text; any non-2xx is an error. +async fn http_get_text(client: &Client, url: &str) -> Result { + let resp = client.get(url).send().await.map_err(|e| e.to_string())?; + let status = resp.status(); + if !status.is_success() { + return Err(format!("HTTP {status}")); + } + resp.text().await.map_err(|e| e.to_string()) +} + +/// Single GET → `Some(text)` on success, `None` on 404, error otherwise. +async fn http_get_text_opt(client: &Client, url: &str) -> Result, String> { + let resp = client.get(url).send().await.map_err(|e| e.to_string())?; + let status = resp.status(); + if status == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !status.is_success() { + return Err(format!("HTTP {status}")); + } + resp.text().await.map(Some).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn artist_info2_url_is_json_and_token_authed() { + let u = build_artist_info2_url("http://nav.local:4533", "u", "p", "ar-1"); + assert!(u.starts_with("http://nav.local:4533/rest/getArtistInfo2.view?")); + assert!(u.contains("id=ar-1")); + assert!(u.contains("f=json")); + assert!(u.contains("&t=") && u.contains("&s=")); + } + + #[test] + fn fanart_url_adds_client_key_only_when_present() { + assert_eq!( + build_fanart_url("mbid-123", "PROJ", None), + "https://webservice.fanart.tv/v3/music/mbid-123?api_key=PROJ" + ); + let byok = build_fanart_url("mbid-123", "PROJ", Some("PERS")); + assert!(byok.contains("api_key=PROJ") && byok.contains("client_key=PERS")); + // empty BYOK is ignored — project key only + assert!(!build_fanart_url("mbid-123", "PROJ", Some("")).contains("client_key")); + } + + #[test] + fn parses_first_artistbackground_url() { + let json = r#"{"artistbackground":[{"id":"1","url":"https://a/bg1.jpg","likes":"9"},{"url":"https://a/bg2.jpg"}]}"#; + let v: serde_json::Value = serde_json::from_str(json).unwrap(); + let bg = v + .get("artistbackground") + .and_then(|a| a.as_array()) + .and_then(|arr| arr.first()) + .and_then(|o| o.get("url")) + .and_then(|u| u.as_str()); + assert_eq!(bg, Some("https://a/bg1.jpg")); + } + + #[test] + fn json_key_maps_surface() { + assert_eq!(fanart_json_key("fanart"), "artistbackground"); + assert_eq!(fanart_json_key("banner"), "musicbanner"); + assert_eq!(fanart_json_key("anything-else"), "artistbackground"); + } + + #[test] + fn normalize_album_strips_trailing_qualifier_only() { + assert_eq!(normalize_album_for_mb("Show No Mercy (2004 Remastered)"), "Show No Mercy"); + assert_eq!(normalize_album_for_mb("Album [Deluxe Edition]"), "Album"); + assert_eq!(normalize_album_for_mb("Reign in Blood"), "Reign in Blood"); + // leading qualifier left intact (does not end with a close bracket) + assert_eq!( + normalize_album_for_mb("(What's the Story) Morning Glory?"), + "(What's the Story) Morning Glory?" + ); + } + + #[test] + fn mb_escape_handles_quotes_and_backslashes() { + assert_eq!(mb_escape("AC/DC"), "AC/DC"); + assert_eq!(mb_escape(r#"a"b"#), r#"a\"b"#); + assert_eq!(mb_escape(r"a\b"), r"a\\b"); + } + + #[test] + fn classify_mb_picks_single_high_score_artist() { + let v: serde_json::Value = serde_json::from_str( + r#"{"releases":[ + {"score":100,"artist-credit":[{"artist":{"id":"mbid-A"}}]}, + {"score":95,"artist-credit":[{"artist":{"id":"mbid-A"}}]}, + {"score":40,"artist-credit":[{"artist":{"id":"mbid-Z"}}]} + ]}"#, + ) + .unwrap(); + assert!(matches!(classify_mb_releases(&v), MbResolution::Found(id) if id == "mbid-A")); + } + + #[test] + fn classify_mb_ambiguous_and_none() { + let two: serde_json::Value = serde_json::from_str( + r#"{"releases":[ + {"score":100,"artist-credit":[{"artist":{"id":"mbid-A"}}]}, + {"score":92,"artist-credit":[{"artist":{"id":"mbid-B"}}]} + ]}"#, + ) + .unwrap(); + assert!(matches!(classify_mb_releases(&two), MbResolution::Ambiguous)); + + let low: serde_json::Value = + serde_json::from_str(r#"{"releases":[{"score":50,"artist-credit":[{"artist":{"id":"x"}}]}]}"#) + .unwrap(); + assert!(matches!(classify_mb_releases(&low), MbResolution::None)); + + let empty: serde_json::Value = serde_json::from_str(r#"{"releases":[]}"#).unwrap(); + assert!(matches!(classify_mb_releases(&empty), MbResolution::None)); + } +} diff --git a/src-tauri/src/cover_cache/external_ensure.rs b/src-tauri/src/cover_cache/external_ensure.rs new file mode 100644 index 00000000..e7bbcb7f --- /dev/null +++ b/src-tauri/src/cover_cache/external_ensure.rs @@ -0,0 +1,408 @@ +//! External artist-artwork ensure path (image-scraper). Split out of `mod.rs` +//! to keep the cover-cache orchestrator navigable: the on-demand fanart/banner +//! fetch (fanart.tv via MBID resolution), the §11 quality gate, the +//! surface-aware peek, and the §12 lookup-table cache. Everything here is gated +//! by `ensure_inner` (feature flag, `!library_bulk`, artist kind) — see the call +//! site in `mod.rs`. Pure code move; behaviour unchanged. + +use super::encode::write_webp_tier; +use super::{decode_image_bytes, disk, external, fetch, peek_fallback_tiers, peek_tier_path}; +use super::CoverCacheEnsureArgs; +use psysonic_library::LibraryRuntime; +use reqwest::Client; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tauri::{AppHandle, Manager}; +use tokio::sync::Semaphore; + +/// Spike negative-cache marker — "this artist has no fanart", mirrors the +/// §11 quality gate for the fanart (16:9) surface: an existing on-disk image +/// pre-empts an external fetch only when it is wide enough AND roughly 16:9. +/// Square Navidrome artist portraits never satisfy it. +const FANART_MIN_WIDTH: u32 = 1280; +const FANART_ASPECT_MIN: f32 = 1.6; +const FANART_ASPECT_MAX: f32 = 2.0; + +/// The external-artwork surfaces fanart.tv serves for an artist. Returns the +/// surface name — also the on-disk file suffix (`{tier}-{surface}.webp`) and the +/// lookup `surface_kind` — when the requested surface is external, else `None`. +pub(super) fn external_surface(surface_kind: Option<&str>) -> Option<&str> { + match surface_kind { + Some("fanart") => Some("fanart"), + Some("banner") => Some("banner"), + _ => None, + } +} + +/// Like [`peek_tier_path`] but, for an external surface (`fanart`/`banner`), +/// serves only the matching `{tier}-{surface}.webp` tiers. If none exist yet it +/// returns None so ensure runs the external branch (fetch; Navidrome is the +/// fallback inside that branch's miss path) instead of short-circuiting on a +/// cached Navidrome tier (§18, "external prioritised"). +pub(super) fn peek_cover_path(dir: &Path, want: u32, args: &CoverCacheEnsureArgs) -> Option { + if let Some(surface) = external_surface(args.surface_kind.as_deref()) { + if let Some(p) = disk::provider_tier_exists(dir, want, surface) { + return Some(p); + } + for &tier in peek_fallback_tiers(want) { + if let Some(p) = disk::provider_tier_exists(dir, tier, surface) { + return Some(p); + } + } + return None; + } + peek_tier_path(dir, want) +} + +fn marker_recent(path: &Path, max_age: Duration) -> bool { + std::fs::metadata(path) + .and_then(|m| m.modified()) + .map(|t| t.elapsed().map(|e| e < max_age).unwrap_or(true)) + .unwrap_or(false) +} + +fn write_marker(path: &Path) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(path, b"1"); +} + +/// §11: do these pixel dimensions satisfy the fanart (16:9) surface? +fn dims_satisfy_fanart(w: u32, h: u32) -> bool { + if w < FANART_MIN_WIDTH || h == 0 { + return false; + } + let aspect = w as f32 / h as f32; + (FANART_ASPECT_MIN..=FANART_ASPECT_MAX).contains(&aspect) +} + +/// §11 quality gate: true when a Navidrome tier already on disk is an HQ ~16:9 +/// image (so the external fetch can be skipped). Reads dimensions only — no +/// full decode. Square artist portraits fail and external proceeds. +fn navidrome_tier_is_hq_fanart(dir: &Path) -> bool { + for &tier in &[2000u32, 800, 512, 256, 128] { + let p = disk::tier_path(dir, tier); + if p.is_file() { + if let Ok((w, h)) = image::image_dimensions(&p) { + if dims_satisfy_fanart(w, h) { + return true; + } + } + } + } + false +} + +fn now_unix_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +/// Read the cached `artist_artwork_lookup` row for a surface off the async +/// executor (§12). +async fn read_artist_lookup( + store: &Option>, + server_id: &str, + artist_id: &str, + surface: &str, +) -> Option { + let store = store.clone()?; + let (server_id, artist_id, surface) = + (server_id.to_string(), artist_id.to_string(), surface.to_string()); + tauri::async_runtime::spawn_blocking(move || { + psysonic_library::artist_artwork::get_artist_artwork(&store, &server_id, &artist_id, &surface) + .ok() + .flatten() + }) + .await + .ok() + .flatten() +} + +/// Upsert an `artist_artwork_lookup` row off the async executor (§12). No-op +/// when the library store is absent (e.g. before login). +#[allow(clippy::too_many_arguments)] +async fn persist_artist_lookup( + store: &Option>, + server_id: &str, + artist_id: &str, + surface: &str, + status: &str, + mbid: Option<&str>, + mbid_source: Option<&str>, + provider: Option<&str>, + now: i64, +) { + let Some(store) = store.clone() else { + return; + }; + let (server_id, artist_id, surface, status) = ( + server_id.to_string(), + artist_id.to_string(), + surface.to_string(), + status.to_string(), + ); + let (mbid, mbid_source, provider) = ( + mbid.map(String::from), + mbid_source.map(String::from), + provider.map(String::from), + ); + let _ = tauri::async_runtime::spawn_blocking(move || { + psysonic_library::artist_artwork::upsert_artist_artwork( + &store, + &server_id, + &artist_id, + &surface, + mbid.as_deref(), + mbid_source.as_deref(), + &status, + provider.as_deref(), + now, + ) + }) + .await; +} + +/// Try to satisfy an external artist `surface` (`fanart` 16:9 background or +/// `banner` strip) from fanart.tv. Writes `{2000,512}-{surface}.webp` into the +/// entity dir and returns the requested-tier path on success. `None` = "no +/// image, fall through to Navidrome" — never writes a `.fetch-failed` marker +/// (§28). +/// +/// MBID resolution stays Rust-side (§23): the tag MBID via `getArtistInfo2`, +/// else a name→MusicBrainz album-confirmed lookup (§19), cached per surface in +/// `artist_artwork_lookup` (§12). The §11 quality gate runs first for the +/// `fanart` surface only. +#[allow(clippy::too_many_arguments)] +pub(super) async fn try_external_fanart( + app: &AppHandle, + args: &CoverCacheEnsureArgs, + dir: &Path, + client: &Client, + fanart_sem: &Arc, + musicbrainz_sem: &Arc, + requested: u32, + surface: &str, +) -> Option { + // Behind the project key: a runtime env var (dev convenience) wins, else the + // key baked in at build time via `option_env!` (release builds). No secret + // lands in the repo. The BYOK personal key is optional (§22). + let api_key = std::env::var("PSYSONIC_FANART_KEY") + .ok() + .or_else(|| option_env!("PSYSONIC_FANART_KEY").map(str::to_string)) + .filter(|k| !k.is_empty())?; + // BYOK personal key (§22): the settings field wins, else the dev env var. + let byok = args + .external_artwork_byok + .as_deref() + .map(str::trim) + .filter(|k| !k.is_empty()) + .map(str::to_string) + .or_else(|| std::env::var("PSYSONIC_FANART_CLIENT_KEY").ok()) + .filter(|k| !k.is_empty()); + + // §11 quality gate applies to the 16:9 `fanart` surface only — if Navidrome + // already serves an HQ ~16:9 image, skip the external fetch. The `banner` + // strip has its own aspect and is never pre-empted by a Navidrome tier. + if surface == "fanart" && navidrome_tier_is_hq_fanart(dir) { + return None; + } + + // §12: the lookup table is both the MBID resolution cache and the negative + // cache. Absent before login → all reads/writes become no-ops. + let store: Option> = + app.try_state::().map(|rt| rt.store.clone()); + let server_id = &args.server_index_key; + let artist_id = &args.cache_entity_id; + let now = now_unix_ms(); + + let cached = read_artist_lookup(&store, server_id, artist_id, surface).await; + if let Some(row) = &cached { + // Back off: no/ambiguous MBID for 24h; a confirmed "no fanart" miss for + // 30 min (also held by the `.miss-fanart` marker). + let within = |window: Duration| now - row.updated_at < window.as_millis() as i64; + match row.status.as_str() { + "no_mbid" | "mbid_ambiguous" if within(Duration::from_secs(24 * 60 * 60)) => { + return None; + } + "miss" if within(Duration::from_secs(30 * 60)) => return None, + _ => {} + } + } + + let miss_marker = dir.join(format!(".miss-{surface}")); + if marker_recent(&miss_marker, Duration::from_secs(30 * 60)) { + return None; + } + + let _permit = fanart_sem.clone().acquire_owned().await.ok()?; + + // §23: resolve the tag MBID Rust-side via getArtistInfo2 — unless the cache + // already carries one (skip the Navidrome round-trip). + let (mbid, mbid_source) = match cached.as_ref().and_then(|r| r.mbid.clone()) { + Some(m) => (m, cached.as_ref().and_then(|r| r.mbid_source.clone())), + None => match external::fetch_artist_tag_mbid( + client, + &args.rest_base_url, + &args.username, + &args.password, + &args.cache_entity_id, + ) + .await + { + Ok(Some(m)) => (m, Some("tag".to_string())), + Ok(None) => { + // No tag MBID. §19: try a name→MusicBrainz album-confirmed lookup + // when both the artist name and an album are in context. + match (args.artist_name.as_deref(), args.album_title.as_deref()) { + (Some(name), Some(album)) + if !name.trim().is_empty() && !album.trim().is_empty() => + { + // ≤1 req/s: hold the single MB permit across the request + // plus a ≥1s spacing so concurrent ensures can't burst MB. + let _mb = musicbrainz_sem.clone().acquire_owned().await.ok()?; + let resolved = + external::resolve_mbid_via_musicbrainz(client, name, album).await; + tokio::time::sleep(Duration::from_millis(1100)).await; + drop(_mb); + match resolved { + Ok(external::MbResolution::Found(m)) => { + (m, Some("musicbrainz".to_string())) + } + Ok(external::MbResolution::Ambiguous) => { + persist_artist_lookup( + &store, server_id, artist_id, surface, "mbid_ambiguous", None, + None, None, now, + ) + .await; + return None; + } + Ok(external::MbResolution::None) => { + persist_artist_lookup( + &store, server_id, artist_id, surface, "no_mbid", None, None, + None, now, + ) + .await; + return None; + } + Err(e) => { + eprintln!("[fanart] musicbrainz failed: {e}"); // transient + return None; + } + } + } + _ => { + // No album context → we could not even *attempt* name→MB. + // Do NOT cache `no_mbid`: a later ensure that arrives with + // album context (e.g. once the artist's album list loads) + // would otherwise be blocked by the 24h backoff. + return None; + } + } + } + Err(e) => { + eprintln!("[fanart] getArtistInfo2 failed: {e}"); // transient — don't cache + return None; + } + }, + }; + + let img_url = match external::fetch_fanart_image_url( + client, + &mbid, + &api_key, + byok.as_deref(), + surface, + ) + .await + { + Ok(Some(u)) => u, + Ok(None) => { + write_marker(&miss_marker); // artist has no image of this kind + persist_artist_lookup( + &store, + server_id, + artist_id, + surface, + "miss", + Some(&mbid), + mbid_source.as_deref(), + None, + now, + ) + .await; + return None; + } + Err(e) => { + eprintln!("[fanart] lookup failed: {e}"); // transient — don't cache + return None; + } + }; + + let bytes = match fetch::fetch_cover_bytes(client, &img_url).await { + Ok(b) => b, + Err(e) => { + eprintln!("[fanart] download failed: {e}"); // transient — don't cache + return None; + } + }; + + // Decode + write {2000,512}-{surface}.webp (matryoshka §17). + let dir_owned = dir.to_path_buf(); + let surface_owned = surface.to_string(); + let encoded = tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { + let img = decode_image_bytes(&bytes)?; + std::fs::create_dir_all(&dir_owned).map_err(|e| e.to_string())?; + for tier in [2000u32, 512u32] { + write_webp_tier(&img, tier, &disk::provider_tier_path(&dir_owned, tier, &surface_owned))?; + } + Ok(()) + }) + .await; + if !matches!(encoded, Ok(Ok(()))) { + eprintln!("[fanart] encode failed: {encoded:?}"); + return None; + } + + persist_artist_lookup( + &store, + server_id, + artist_id, + surface, + "hit", + Some(&mbid), + mbid_source.as_deref(), + Some("fanart"), + now, + ) + .await; + + // NOTE: do NOT emit `cover:tier-ready` here. That event is keyed by the + // canonical cover key (cacheKind/cacheEntityId/tier, no surface), so emitting + // it with the `{tier}-{surface}.webp` path would seed the frontend disk-src + // cache for the *Navidrome* artist cover with the external image — leaking + // fanart/banner into the plain artist cover (avatar, FS "navidrome-artist" + // fallback) even with the scraper off. The external hooks read the path from + // this function's return value, so no event is needed. + Some(disk::provider_tier_path(dir, requested, surface)) +} + +#[cfg(test)] +mod fanart_gate_tests { + use super::dims_satisfy_fanart; + + #[test] + fn gate_accepts_wide_16_9_and_rejects_square_or_small() { + assert!(dims_satisfy_fanart(2000, 1125)); // 16:9, wide + assert!(dims_satisfy_fanart(1280, 800)); // aspect 1.6 boundary + assert!(dims_satisfy_fanart(1280, 640)); // aspect 2.0 boundary + assert!(!dims_satisfy_fanart(2000, 2000)); // square portrait + assert!(!dims_satisfy_fanart(1000, 560)); // width < 1280 + assert!(!dims_satisfy_fanart(1280, 600)); // aspect 2.13 > 2.0 + assert!(!dims_satisfy_fanart(1280, 0)); // div-by-zero guard + } +} diff --git a/src-tauri/src/cover_cache/fetch.rs b/src-tauri/src/cover_cache/fetch.rs index b4143973..0bca246f 100644 --- a/src-tauri/src/cover_cache/fetch.rs +++ b/src-tauri/src/cover_cache/fetch.rs @@ -19,12 +19,15 @@ fn random_salt() -> String { format!("{nanos:x}") } -pub fn build_cover_art_url( +/// Build a token-authed Subsonic REST URL `{rest_base}/rest/{endpoint}.view` +/// with the standard `u/t/s/v/c` auth params plus the given `extra` query +/// pairs. Shared by all Subsonic GETs (cover art, `getArtistInfo2`, …). +pub(crate) fn build_subsonic_url( rest_base: &str, + endpoint: &str, username: &str, password: &str, - cover_art_id: &str, - size: u32, + extra: &[(&str, &str)], ) -> String { let base = rest_base.trim_end_matches('/'); let api_base = if base.ends_with("/rest") { @@ -34,25 +37,43 @@ pub fn build_cover_art_url( }; let salt = random_salt(); let token = format!("{:x}", md5::compute(format!("{password}{salt}"))); - let endpoint = format!("{api_base}/getCoverArt.view"); + let endpoint_url = format!("{api_base}/{endpoint}.view"); let mut serializer = url::form_urlencoded::Serializer::new(String::new()); - serializer.append_pair("id", cover_art_id); - serializer.append_pair("size", &size.to_string()); + for (k, v) in extra { + serializer.append_pair(k, v); + } serializer.append_pair("u", username); serializer.append_pair("t", &token); serializer.append_pair("s", &salt); serializer.append_pair("v", "1.16.1"); serializer.append_pair("c", SUBSONIC_CLIENT); let query = serializer.finish(); - match Url::parse(&endpoint) { + match Url::parse(&endpoint_url) { Ok(mut url) => { url.set_query(Some(&query)); url.to_string() } - Err(_) => format!("{endpoint}?{query}"), + Err(_) => format!("{endpoint_url}?{query}"), } } +pub fn build_cover_art_url( + rest_base: &str, + username: &str, + password: &str, + cover_art_id: &str, + size: u32, +) -> String { + let size_s = size.to_string(); + build_subsonic_url( + rest_base, + "getCoverArt", + username, + password, + &[("id", cover_art_id), ("size", &size_s)], + ) +} + /// Outcome of a single fetch attempt: transient errors are worth retrying, /// permanent ones (a real 4xx like 404 — the cover simply does not exist) are /// not, so we never hammer the server for genuinely-missing art. diff --git a/src-tauri/src/cover_cache/mod.rs b/src-tauri/src/cover_cache/mod.rs index 7284cfd2..eb159a7c 100644 --- a/src-tauri/src/cover_cache/mod.rs +++ b/src-tauri/src/cover_cache/mod.rs @@ -3,6 +3,8 @@ mod backfill_worker; mod disk; mod encode; +mod external; +mod external_ensure; mod fetch; use disk::{cover_dir, tier_exists, tier_path, DERIVE_TIERS}; @@ -133,6 +135,27 @@ pub struct CoverCacheEnsureArgs { /// with the album/artist name. On-demand UI ensures leave it `None`. #[serde(default)] pub library_server_id: Option, + /// Image-scraper spike (§16 P0): when true, an artist `fanart` ensure may + /// fetch a fanart.tv background into `{tier}-fanart.webp`. Inert unless the + /// fanart project key is present (`PSYSONIC_FANART_KEY`). Off by default. + #[serde(default)] + pub external_artwork_enabled: bool, + /// Surface intent for external artwork — `fanart` for the 16:9 artist + /// background. `None` on plain cover ensures. + #[serde(default)] + pub surface_kind: Option, + /// Artist display name — context for the §19 name→MusicBrainz fallback when + /// the artist carries no tag MBID. `None` skips that fallback. + #[serde(default)] + pub artist_name: Option, + /// Album title currently in context (fullscreen playback) — disambiguates + /// the name→MusicBrainz query (§19). + #[serde(default)] + pub album_title: Option, + /// Optional BYOK personal fanart.tv key from settings — sent in addition to + /// the project key (§22). Falls back to the `PSYSONIC_FANART_CLIENT_KEY` env. + #[serde(default)] + pub external_artwork_byok: Option, } fn cover_dir_for_args(root: &Path, args: &CoverCacheEnsureArgs) -> PathBuf { @@ -148,6 +171,9 @@ const COVER_CPU_UI_CONCURRENCY: usize = 2; const COVER_CPU_BACKFILL_CONCURRENCY: usize = 2; /// Upper bound for the runtime encode-pool knob (matches the worker cap). const COVER_CPU_BACKFILL_MAX: usize = 16; +/// External providers (fanart.tv) get their own low-concurrency HTTP lane so +/// they can never starve Navidrome cover / getArtistInfo2 fetches (§26). +const FANART_HTTP_CONCURRENCY: usize = 4; pub struct CoverCacheState { pub root: PathBuf, @@ -158,6 +184,13 @@ pub struct CoverCacheState { pub http_sem: Arc, pub cover_cpu_ui_sem: Arc, pub cover_cpu_backfill_sem: Arc, + /// External-provider (fanart.tv) HTTP lane — separate from `http_sem` so + /// external fetches never starve Navidrome cover / getArtistInfo2 (§26). + pub fanart_http_sem: Arc, + /// MusicBrainz name→MBID lane — a single permit, so the §19 resolver runs + /// strictly serially and the caller's ≥1s spacing keeps us under MB's rate + /// limit (their ToS). + pub musicbrainz_sem: Arc, /// Live permit count of `cover_cpu_backfill_sem` (the semaphore itself only /// exposes *available* permits, not the configured ceiling). cover_cpu_backfill_max: AtomicUsize, @@ -180,6 +213,8 @@ impl CoverCacheState { http_sem: Arc::new(Semaphore::new(COVER_HTTP_CONCURRENCY)), cover_cpu_ui_sem: Arc::new(Semaphore::new(COVER_CPU_UI_CONCURRENCY)), cover_cpu_backfill_sem: Arc::new(Semaphore::new(COVER_CPU_BACKFILL_CONCURRENCY)), + fanart_http_sem: Arc::new(Semaphore::new(FANART_HTTP_CONCURRENCY)), + musicbrainz_sem: Arc::new(Semaphore::new(1)), cover_cpu_backfill_max: AtomicUsize::new(COVER_CPU_BACKFILL_CONCURRENCY), }) } @@ -229,7 +264,7 @@ impl CoverCacheState { ) -> Result { let this = state.lock().await; let dir = cover_dir_for_args(&this.root, args); - if let Some(path) = peek_tier_path(&dir, args.tier) { + if let Some(path) = external_ensure::peek_cover_path(&dir, args.tier, args) { return Ok(CoverCacheEnsureResult { hit: true, path: path.to_string_lossy().into_owned(), @@ -254,6 +289,8 @@ impl CoverCacheState { let root = this.root.clone(); let http_sem = http_sem_override.unwrap_or_else(|| this.http_sem.clone()); let cover_cpu_sem = this.cpu_sem_for(args.library_bulk); + let fanart_sem = this.fanart_http_sem.clone(); + let musicbrainz_sem = this.musicbrainz_sem.clone(); drop(this); if cover_fetch_recently_failed(&dir) { @@ -264,6 +301,33 @@ impl CoverCacheState { }); } + // For an external artist surface (`fanart` 16:9 background or `banner` + // strip), try fanart.tv before the Navidrome fallback. On any miss it + // falls through WITHOUT writing a `.fetch-failed` marker, so Navidrome + // stays the display fallback (§28). + if args.external_artwork_enabled && !args.library_bulk && args.cache_kind == "artist" { + if let Some(surface) = external_ensure::external_surface(args.surface_kind.as_deref()) { + if let Some(path) = external_ensure::try_external_fanart( + app, + args, + &dir, + &client, + &fanart_sem, + &musicbrainz_sem, + args.tier, + surface, + ) + .await + { + return Ok(CoverCacheEnsureResult { + hit: true, + path: path.to_string_lossy().into_owned(), + tier: args.tier, + }); + } + } + } + let requested = args.tier; let quiet = args.library_bulk; let tiers_now: Vec = if args.library_bulk { @@ -824,6 +888,7 @@ fn peek_tier_path(dir: &Path, want: u32) -> Option { None } + #[tauri::command] pub async fn cover_cache_ensure( app: AppHandle, @@ -923,6 +988,17 @@ pub async fn cover_cache_clear_server( } invalidate_dir_usage_cache(&server_index_key); drop(guard); + // §12/B.4: the on-disk external tiers (`{tier}-fanart.webp` / `-banner.webp`) + // + `.miss-*` markers went with the dir removal above; also drop the + // `artist_artwork_lookup` rows for this server so no resolution state lingers. + if let Some(rt) = app.try_state::() { + let store = rt.store.clone(); + let key = server_index_key.clone(); + let _ = tauri::async_runtime::spawn_blocking(move || { + psysonic_library::artist_artwork::clear_artist_artwork_for_server(&store, &key) + }) + .await; + } // Clearing drops files the cheap idle-gate signature can't see, so re-arm // the backfill worker — otherwise the next sync-idle would skip the rescan. if let Some(worker) = app.try_state::>() { @@ -935,6 +1011,65 @@ pub async fn cover_cache_clear_server( Ok(()) } +/// Delete only external-provider artifacts under a server's cover dir — the +/// `{tier}-{provider}.webp` tiers and `.miss-{provider}` markers — leaving the +/// canonical Navidrome `{tier}.webp` and `.fetch-failed` untouched (Navidrome +/// tiers have no `-` in the stem; their marker is `.fetch-failed`, not +/// `.miss-*`). FS-only so it is testable against a real `tempdir`. Returns the +/// number of files removed. +fn purge_external_files(server_dir: &Path) -> usize { + fn is_external(name: &str) -> bool { + (name.ends_with(".webp") && name.contains('-')) || name.starts_with(".miss-") + } + fn walk(dir: &Path, count: &mut usize) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + walk(&p, count); + } else if p.file_name().and_then(|n| n.to_str()).is_some_and(is_external) + && std::fs::remove_file(&p).is_ok() + { + *count += 1; + } + } + } + let mut count = 0; + walk(server_dir, &mut count); + count +} + +/// Opt-out purge (§9, §12, Appendix B.4): drop every external artwork artifact +/// for a server — `{tier}-{provider}.webp`, `.miss-{provider}`, and the +/// `artist_artwork_lookup` rows — while leaving the canonical Navidrome covers +/// intact. Fired when the user turns the External Artwork toggle off. Unlike +/// `cover_cache_clear_server`, Navidrome tiers survive. +#[tauri::command] +pub async fn cover_cache_purge_external( + app: AppHandle, + server_index_key: String, +) -> Result<(), String> { + let st = state(&app)?; + let guard = st.lock().await; + let path = cover_server_dir(&guard.root, &server_index_key); + if path.is_dir() { + purge_external_files(&path); + } + invalidate_dir_usage_cache(&server_index_key); + drop(guard); + if let Some(rt) = app.try_state::() { + let store = rt.store.clone(); + let key = server_index_key.clone(); + let _ = tauri::async_runtime::spawn_blocking(move || { + psysonic_library::artist_artwork::clear_artist_artwork_for_server(&store, &key) + }) + .await; + } + Ok(()) +} + /// Rename a server's cover-cache bucket on disk after the user edits the /// primary URL (and the derived index key changes). Used by the URL-change /// remigration pipeline (dual-server-address spec §8.3) so cached covers @@ -1214,7 +1349,10 @@ mod tests { use super::decode_image_bytes; use super::disk::{cover_dir, tier_path}; - use super::{count_cached_cover_ids, is_safe_index_key, merge_cover_bucket, rename_bucket_inner}; + use super::{ + count_cached_cover_ids, is_safe_index_key, merge_cover_bucket, purge_external_files, + rename_bucket_inner, + }; use psysonic_core::cover_cache_layout::CANONICAL_PROGRESS_TIER; use std::fs; use std::path::PathBuf; @@ -1409,4 +1547,33 @@ mod tests { ); let _ = fs::remove_dir_all(&root); } + + #[test] + fn purge_external_removes_only_external_artifacts() { + let root = fresh_tmpdir("purge-external"); + let entity = root.join("artist").join("ar-1"); + fs::create_dir_all(&entity).unwrap(); + // Navidrome canonical — must survive. + fs::write(entity.join("2000.webp"), b"n").unwrap(); + fs::write(entity.join("512.webp"), b"n").unwrap(); + fs::write(entity.join(".fetch-failed"), b"1").unwrap(); + // External — must go. + fs::write(entity.join("2000-fanart.webp"), b"f").unwrap(); + fs::write(entity.join("512-fanart.webp"), b"f").unwrap(); + fs::write(entity.join("2000-banner.webp"), b"b").unwrap(); + fs::write(entity.join(".miss-fanart"), b"1").unwrap(); + fs::write(entity.join(".miss-banner"), b"1").unwrap(); + + assert_eq!(purge_external_files(&root), 5); + + assert!(entity.join("2000.webp").exists()); + assert!(entity.join("512.webp").exists()); + assert!(entity.join(".fetch-failed").exists()); + assert!(!entity.join("2000-fanart.webp").exists()); + assert!(!entity.join("512-fanart.webp").exists()); + assert!(!entity.join("2000-banner.webp").exists()); + assert!(!entity.join(".miss-fanart").exists()); + assert!(!entity.join(".miss-banner").exists()); + let _ = fs::remove_dir_all(&root); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f9f3ea73..5226014f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -843,6 +843,7 @@ pub fn run() { cover_cache::cover_cache_configure, cover_cache::cover_cache_clear, cover_cache::cover_cache_clear_server, + cover_cache::cover_cache_purge_external, cover_cache::cover_cache_rename_server_bucket, cover_cache::cover_cache_stats_server, cover_cache::cover_cache_get_pipeline_queue_stats, diff --git a/src/api/coverCache.ts b/src/api/coverCache.ts index eb88cc8f..345f34a0 100644 --- a/src/api/coverCache.ts +++ b/src/api/coverCache.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from '../store/authStore'; +import { useThemeStore } from '../store/themeStore'; import { coverIndexKeyFromRef, coverStorageKeyFromRef } from '../cover/storageKeys'; import { connectBaseUrlForServer } from '../utils/server/serverEndpoint'; import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey'; @@ -52,7 +53,36 @@ export function setCoverCacheAutoDownloadEnabled(enabled: boolean): void { coverAutoDownloadEnabled = enabled; } -function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) { +export type CoverEnsureOpts = { + /** External-artwork surface intent — `'fanart'` for the 16:9 artist background (§28). */ + surfaceKind?: string; + /** §19 name→MusicBrainz context: the artist display name + the album in context. */ + artistName?: string; + albumTitle?: string; +}; + +/** + * External-artwork ensure fields (§28). `externalArtworkEnabled` is gated by the + * master toggle AND restricted to the external artist surfaces (`fanart` / + * `banner`), so plain album/artist cover ensures are never affected. + */ +function externalEnsureFields(ref: CoverArtRef, opts?: CoverEnsureOpts) { + const surfaceKind = opts?.surfaceKind; + const isExternalSurface = surfaceKind === 'fanart' || surfaceKind === 'banner'; + const theme = useThemeStore.getState(); + const externalArtworkEnabled = + isExternalSurface && ref.cacheKind === 'artist' && theme.externalArtworkEnabled; + return { + externalArtworkEnabled, + surfaceKind, + artistName: opts?.artistName, + albumTitle: opts?.albumTitle, + // BYOK personal fanart.tv key (§22), only when the external branch will run. + externalArtworkByok: externalArtworkEnabled ? theme.externalArtworkByok : undefined, + }; +} + +function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier, opts?: CoverEnsureOpts) { const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const scope = ref.serverScope; if (scope.kind === 'server') { @@ -69,6 +99,7 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) { ), username: scope.username, password: scope.password, + ...externalEnsureFields(ref, opts), }; } const server = @@ -94,6 +125,7 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) { restBaseUrl: baseUrl ? coverCacheRestHost(baseUrl) : '', username: server?.username ?? '', password: server?.password ?? '', + ...externalEnsureFields(ref, opts), }; } @@ -125,9 +157,10 @@ export async function coverCacheEnsure( ref: CoverArtRef, tier: CoverArtTier, _priority?: string, + opts?: CoverEnsureOpts, ): Promise { return invoke('cover_cache_ensure', { - args: ensureArgsFromRef(ref, tier), + args: ensureArgsFromRef(ref, tier, opts), }); } @@ -156,6 +189,23 @@ export async function coverCacheClearServer(serverIndexKey: string): Promise { + const { servers } = useAuthStore.getState(); + await Promise.all( + servers.map(s => + invoke('cover_cache_purge_external', { + serverIndexKey: serverIndexKeyForProfile(s), + }).catch(() => undefined), + ), + ); +} + export async function coverCacheStatsServer( serverIndexKey: string, ): Promise> { diff --git a/src/components/artistDetail/ArtistDetailHero.tsx b/src/components/artistDetail/ArtistDetailHero.tsx index 497a3940..ebe2399b 100644 --- a/src/components/artistDetail/ArtistDetailHero.tsx +++ b/src/components/artistDetail/ArtistDetailHero.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAlbumDetailBack } from '../../hooks/useAlbumDetailBack'; import { @@ -11,6 +11,7 @@ import { useAuthStore } from '../../store/authStore'; import { useArtistOfflineState } from '../../hooks/useArtistOfflineState'; import { useIsMobile } from '../../hooks/useIsMobile'; import { ArtistHeroCover } from '../../cover/artistHero'; +import { useArtistBanner, useArtistFanart } from '../../cover/useArtistFanart'; import { useCoverLightboxSrc } from '../../cover/lightbox'; import type { CoverArtRef } from '../../cover/types'; import LastfmIcon from '../LastfmIcon'; @@ -45,6 +46,48 @@ interface Props { actionPolicy?: OfflineActionPolicy; } +/** + * Artist-detail header background (banner / fanart). Preloads the final image + * and only then fades it in over the empty header — so the chosen image never + * hard-cuts and no intermediate source flashes first. Reuses the shared + * `album-detail-bg` / `-overlay` structure; the fade is a scoped inline opacity + * so the class stays untouched for the album/playlist headers that share it. + * + * Mount with `key={url}` for a fresh element (and `loaded=false`) per source. + * Both load paths are covered: `onLoad` for a network fetch, and the `ref`'s + * `complete` check for an already-cached image whose `load` event can fire + * before React attaches the handler. + */ +function ArtistHeaderBg({ url }: { url: string }) { + const [loaded, setLoaded] = useState(false); + if (!url) return null; + return ( + <> + {/* Hidden preloader — drives `loaded`; the visible background is CSS. */} + setLoaded(true)} + ref={(el) => { + if (el?.complete) setLoaded(true); + }} + /> +