Files
Psychotoxical-psysonic/src/api/coverCache.ts
T
Psychotoxical b950d4704b feat(cover): artist artwork from fanart.tv (off by default) (#1137)
* 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::<LibraryRuntime>().

* 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-<surface>) 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)
2026-06-20 21:04:21 +02:00

325 lines
11 KiB
TypeScript

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';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { restBaseFromUrl } from './subsonicClient';
import type { CoverArtRef, CoverArtTier } from '../cover/types';
/** Library SQLite `track.server_id` uses host index keys, not auth profile UUIDs. */
export function librarySqlServerId(profileOrIndexServerId: string): string {
const server = useAuthStore.getState().servers.find(s => s.id === profileOrIndexServerId);
if (server) return serverIndexKeyForProfile(server);
return profileOrIndexServerId;
}
/** Host root for Rust `build_cover_art_url` (`{host}/rest/getCoverArt.view`). */
export function coverCacheRestHost(serverUrl: string): string {
return restBaseFromUrl(serverUrl).replace(/\/rest$/i, '');
}
export type CoverCacheEnsureResult = {
hit: boolean;
path: string;
tier: CoverArtTier;
};
export type CoverCacheStats = {
bytes: number;
count: number;
pressure: 'ok' | 'pressure' | 'full';
autoDownloadEnabled: boolean;
entryCount: number;
};
export type CoverPipelineQueueStatsDto = {
httpMax: number;
httpActive: number;
cpuUiMax: number;
cpuUiActive: number;
cpuBackfillMax: number;
cpuBackfillActive: number;
libraryBackfillHttpMax: number;
libraryBackfillHttpActive: number;
libraryBackfillPassRunning: boolean;
uiEnsuredTotal: number;
};
let coverAutoDownloadEnabled = true;
export function setCoverCacheAutoDownloadEnabled(enabled: boolean): void {
coverAutoDownloadEnabled = enabled;
}
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') {
// scope.url is the index-stable primary; the Rust cover fetcher needs
// the runtime connect URL (LAN or public, whichever currently answers).
return {
serverIndexKey: coverIndexKeyFromRef(ref),
cacheKind: ref.cacheKind,
cacheEntityId: ref.cacheEntityId,
coverArtId: ref.fetchCoverArtId,
tier,
restBaseUrl: coverCacheRestHost(
connectBaseUrlForServer({ id: scope.serverId, url: scope.url }),
),
username: scope.username,
password: scope.password,
...externalEnsureFields(ref, opts),
};
}
const server =
scope.kind === 'playback'
? (() => {
const playbackServerId = getPlaybackServerId();
if (playbackServerId) {
const playbackServer = useAuthStore
.getState()
.servers.find(s => s.id === playbackServerId);
if (playbackServer) return playbackServer;
}
return getActiveServer();
})()
: getActiveServer();
const baseUrl = server ? connectBaseUrlForServer(server) : getBaseUrl();
return {
serverIndexKey: coverIndexKeyFromRef(ref),
cacheKind: ref.cacheKind,
cacheEntityId: ref.cacheEntityId,
coverArtId: ref.fetchCoverArtId,
tier,
restBaseUrl: baseUrl ? coverCacheRestHost(baseUrl) : '',
username: server?.username ?? '',
password: server?.password ?? '',
...externalEnsureFields(ref, opts),
};
}
export type CoverCachePeekItem = {
serverIndexKey: string;
cacheKind: 'album' | 'artist';
cacheEntityId: string;
tier: CoverArtTier;
storageKey: string;
};
/** Disk-only — no HTTP. Returns map storageKey → absolute .webp path. */
export async function coverCachePeekBatch(
refs: CoverArtRef[],
tier: CoverArtTier,
): Promise<Record<string, string>> {
if (refs.length === 0) return {};
const items: CoverCachePeekItem[] = refs.map(ref => ({
serverIndexKey: coverIndexKeyFromRef(ref),
cacheKind: ref.cacheKind,
cacheEntityId: ref.cacheEntityId,
tier,
storageKey: coverStorageKeyFromRef(ref, tier),
}));
return invoke<Record<string, string>>('cover_cache_peek_batch', { items });
}
export async function coverCacheEnsure(
ref: CoverArtRef,
tier: CoverArtTier,
_priority?: string,
opts?: CoverEnsureOpts,
): Promise<CoverCacheEnsureResult> {
return invoke<CoverCacheEnsureResult>('cover_cache_ensure', {
args: ensureArgsFromRef(ref, tier, opts),
});
}
export async function coverCacheEnsureBatch(
refs: CoverArtRef[],
tier: CoverArtTier,
_priority?: string,
): Promise<void> {
if (refs.length === 0) return;
const items = refs.map(ref => ensureArgsFromRef(ref, tier));
await invoke('cover_cache_ensure_batch', { items });
}
export async function coverCacheStats(): Promise<CoverCacheStats> {
const stats = await invoke<CoverCacheStats>('cover_cache_stats', {});
setCoverCacheAutoDownloadEnabled(stats.autoDownloadEnabled);
return stats;
}
/** Clears all servers (legacy). Prefer `coverCacheClearServer`. */
export async function coverCacheClear(): Promise<void> {
return invoke('cover_cache_clear', {});
}
export async function coverCacheClearServer(serverIndexKey: string): Promise<void> {
return invoke('cover_cache_clear_server', { serverIndexKey });
}
/**
* Opt-out purge: when the External Artwork toggle is turned off, drop every
* fetched external image + `.miss-*` marker + lookup row across all configured
* servers (Navidrome covers are left intact). Fire-and-forget; per-server
* failures are swallowed so one unreachable server can't block the rest.
*/
export async function purgeExternalArtworkAllServers(): Promise<void> {
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<Pick<CoverCacheStats, 'bytes' | 'entryCount'>> {
const stats = await invoke<CoverCacheStats>('cover_cache_stats_server', { serverIndexKey });
return { bytes: stats.bytes, entryCount: stats.entryCount };
}
export function coverGetPipelineQueueStats(): Promise<CoverPipelineQueueStatsDto> {
return invoke<CoverPipelineQueueStatsDto>('cover_cache_get_pipeline_queue_stats');
}
export async function libraryCoverBackfillBatch(
serverIndexKey: string,
libraryServerId: string,
cursor?: string | null,
limit?: number,
): Promise<{ coverIds: string[]; nextCursor: string | null; exhausted: boolean }> {
const sqlServerId = librarySqlServerId(libraryServerId);
const diskKey = serverIndexKey || sqlServerId;
return invoke('library_cover_backfill_batch', {
serverIndexKey: diskKey,
libraryServerId: sqlServerId,
cursor,
limit,
});
}
export async function libraryCoverProgress(
serverIndexKey: string,
libraryServerId: string,
): Promise<{ totalDistinct: number; pending: number; done: number }> {
const sqlServerId = librarySqlServerId(libraryServerId);
const diskKey = serverIndexKey || sqlServerId;
return invoke('library_cover_progress', {
serverIndexKey: diskKey,
libraryServerId: sqlServerId,
});
}
export type LibraryCoverBackfillConfigureArgs = {
enabled: boolean;
serverIndexKey: string;
libraryServerId: string;
restBaseUrl: string;
username: string;
password: string;
};
export async function libraryCoverBackfillConfigure(
args: LibraryCoverBackfillConfigureArgs,
): Promise<void> {
return invoke('library_cover_backfill_configure', args);
}
/**
* Push the current reachable connect URL to the native backfill worker without
* rebuilding the session. The worklist is URL-agnostic; each fetch reads this
* value live, so a LAN→public flip is honoured by the in-flight pass too. A real
* change clears the stale fetch-failed backoff and kicks a retry pass.
*/
export async function libraryCoverBackfillSetBaseUrl(restBaseUrl: string): Promise<void> {
return invoke('library_cover_backfill_set_base_url', { restBaseUrl });
}
export type CoverBackfillPulseResult = {
scheduled: number;
exhausted: boolean;
pending: number;
done: number;
total: number;
status: 'idle' | 'active' | 'blocked_sync' | 'blocked_pressure' | 'disabled' | string;
};
/** One backfill step (legacy); prefer `libraryCoverBackfillRunFullPass`. */
export async function libraryCoverBackfillPulse(): Promise<CoverBackfillPulseResult> {
return invoke<CoverBackfillPulseResult>('library_cover_backfill_pulse');
}
/**
* Start one full-catalog pass on the native runtime (works when the window is inactive).
* `force` bypasses the idle gate and clears the fetch-failed backoff so previously
* unfetchable (404) covers are retried — used by the manual "Run full pass now".
*/
export async function libraryCoverBackfillRunFullPass(
force = false,
): Promise<{ started: boolean }> {
return invoke<{ started: boolean }>('library_cover_backfill_run_full_pass', { force });
}
export async function libraryCoverBackfillResetCursor(): Promise<void> {
return invoke('library_cover_backfill_reset_cursor');
}
/** Yield native library backfill while the user navigates (visible covers first). */
export async function libraryCoverBackfillSetUiPriority(hold: boolean): Promise<void> {
return invoke('library_cover_backfill_set_ui_priority', { hold });
}
/** Perf-probe only: retune cover backfill threads (download + encode). Returns the clamped value applied. */
export async function libraryCoverBackfillSetParallel(threads: number): Promise<number> {
return invoke<number>('library_cover_backfill_set_parallel', { threads });
}
export async function libraryCoverClearFetchFailures(serverIndexKey: string): Promise<number> {
return invoke<number>('library_cover_clear_fetch_failures', { serverIndexKey });
}
export async function libraryCoverCatalogSize(libraryServerId: string): Promise<number> {
return invoke<number>('library_cover_catalog_size', {
libraryServerId: librarySqlServerId(libraryServerId),
});
}
export function coverCacheMayBackgroundDownload(): boolean {
return coverAutoDownloadEnabled;
}