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)
This commit is contained in:
Psychotoxical
2026-06-20 21:04:21 +02:00
committed by GitHub
parent 23f8008248
commit b950d4704b
33 changed files with 1563 additions and 73 deletions
+52 -2
View File
@@ -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<CoverCacheEnsureResult> {
return invoke<CoverCacheEnsureResult>('cover_cache_ensure', {
args: ensureArgsFromRef(ref, tier),
args: ensureArgsFromRef(ref, tier, opts),
});
}
@@ -156,6 +189,23 @@ export async function coverCacheClearServer(serverIndexKey: string): Promise<voi
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'>> {
@@ -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. */}
<img
src={url}
alt=""
aria-hidden="true"
style={{ display: 'none' }}
onLoad={() => setLoaded(true)}
ref={(el) => {
if (el?.complete) setLoaded(true);
}}
/>
<div
className="album-detail-bg"
style={{
backgroundImage: `url(${url})`,
opacity: loaded ? 1 : 0,
transition: 'opacity 0.4s ease',
}}
aria-hidden="true"
/>
{loaded && <div className="album-detail-overlay" aria-hidden="true" />}
</>
);
}
export default function ArtistDetailHero({
artist, id, albums, info, isStarred, artistEntityRating, handleArtistEntityRating,
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
@@ -71,22 +114,53 @@ export default function ArtistDetailHero({
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, { alt: artist.name });
// Artist-detail header banner (§28, Option B): fanart.tv banner → the 16:9
// fanart background cropped to the strip → empty (no regression when off).
// Use the LOADED artist's id (not the route `id`), so the id, name and album
// handed to the external-artwork hooks always describe the SAME artist. The
// route `id` flips immediately on navigation while `artist`/`albums` refetch
// a beat later — that mismatch previously wrote the previous artist's image
// under the new artist's key (Sepultura's image under Lordi's id).
const artistKey = artist.id;
// An album from the artist's own list gives the §19 name→MusicBrainz fallback
// the context it needs when the artist carries no Navidrome tag MBID.
// Pick the first album that actually belongs to THIS artist. `albums` refetches
// a beat after `artist` on navigation, so a stale album would run a mismatched
// name→MusicBrainz query and could cache a wrong `no_mbid` for the new artist.
const albumContext = albums.find((a) => a.artistId === artist.id)?.name;
const banner = useArtistBanner(artistKey, {
artistName: artist.name,
albumTitle: albumContext,
});
const fanartBg = useArtistFanart(artistKey, {
artistName: artist.name,
albumTitle: albumContext,
});
// Banner is preferred: while it is still resolving, show nothing rather than
// flashing the fanart background first and then swapping. Only once the banner
// has resolved to a miss do we fall back to the 16:9 fanart (which is itself
// '' while still loading). Empty when neither has an image. Off → both '',
// not pending → empty, no regression.
const headerBgUrl = banner.src || (banner.pending ? '' : fanartBg.src);
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
return (
<>
<button
className="btn btn-ghost"
onClick={() => goBack()}
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
>
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
</button>
{lightbox}
<div className="artist-detail-header">
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
{/* Same structure + classes as the album-detail header (AlbumHeader.tsx),
with the fanart banner as the background instead of the album cover.
`artist-detail-bleed` breaks out of the artist page's .content-body
padding so it is full-bleed like the album page (flush .album-detail). */}
<div className="album-detail-header artist-detail-bleed">
<ArtistHeaderBg key={headerBgUrl} url={headerBgUrl} />
<div className="album-detail-content">
<button className="btn btn-ghost album-detail-back" onClick={() => goBack()}>
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
</button>
<div className="album-detail-hero">
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
{coverId ? (
<button
className="artist-detail-avatar-btn"
@@ -274,6 +348,8 @@ export default function ArtistDetailHero({
</button>
)}
</div>
</div>
</div>
</div>
</div>
</>
@@ -6,10 +6,11 @@ import {
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../../store/playerStore';
import { queueSongStar, queueSongRating } from '../../store/pendingStarSync';
import { useAlbumCoverRef } from '../../cover/useLibraryCoverRef';
import { useAlbumCoverRef, useArtistCoverRef } from '../../cover/useLibraryCoverRef';
import { usePlaybackCoverArt } from '../../hooks/usePlaybackCoverArt';
import { useCachedUrl } from '../CachedImage';
import { useFsArtistPortrait } from '../../hooks/useFsArtistPortrait';
import { useArtistFanart } from '../../cover/useArtistFanart';
import { useThemeStore } from '../../store/themeStore';
import { useFsIdleFade } from '../../hooks/useFsIdleFade';
import { useQueueTrackAt } from '../../hooks/useQueueTracks';
import WaveformSeek from '../WaveformSeek';
@@ -22,6 +23,35 @@ import { FsTimeReadout } from './FsTimeReadout';
interface Props {
onClose: () => void;
}
/**
* Fullscreen background image that eases in once its pixels are loaded, so a
* new background fades up from the empty backdrop instead of hard-cutting.
*
* Mount it with `key={url}` so every source gets a fresh element (and a fresh
* `loaded=false`). Both load paths are covered: `onLoad` for a network/disk
* fetch, and the `ref`'s `complete` check for an already-cached image whose
* `load` event can fire before React attaches the handler (e.g. skipping back
* to a recently shown artist) — without it the background would stay black.
*/
function FsBackground({ url }: { url: string }) {
const [loaded, setLoaded] = useState(false);
if (!url) return <div className="fsp-bg fsp-bg--empty" aria-hidden="true" />;
return (
<img
className={`fsp-bg${loaded ? ' is-loaded' : ''}`}
src={url}
onLoad={() => setLoaded(true)}
ref={(el) => {
if (el?.complete) setLoaded(true);
}}
alt=""
aria-hidden="true"
draggable={false}
/>
);
}
export default function FullscreenPlayerStatic({ onClose }: Props) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
@@ -56,17 +86,32 @@ export default function FullscreenPlayerStatic({ onClose }: Props) {
// `usePlaybackCoverArt` still re-scopes it to the playback server.
const playbackCoverRef =
useAlbumCoverRef(currentTrack?.albumId, undefined, undefined, { libraryResolve: false }) ?? undefined;
// One high-res cover (cucadmuh's fullRes 2000px path) feeds both the background
// fallback and the foreground thumbnail: crisp instead of the old low-res tier,
// and a single fetch/decode shared by both.
// One high-res cover (cucadmuh's fullRes 2000px path) feeds the foreground
// thumbnail crisp instead of the old low-res tier. It is no longer a
// background source (see below).
const cover = usePlaybackCoverArt(playbackCoverRef, 2000, { fullRes: true });
// `true` = show the raw URL immediately while the blob resolves (same as FsArt).
const coverUrl = useCachedUrl(cover.src, cover.cacheKey, true);
const resolvedCoverUrl = coverUrl;
const thumbUrl = coverUrl;
// Artist photo is the background; fall back to the album cover.
const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId);
const bgUrl = artistBgUrl || resolvedCoverUrl;
// Background (§28). The album cover is deliberately NOT a background source —
// it only ever feeds the foreground thumbnail. With the scraper on, the
// fanart.tv 16:9 image is the background; while it resolves the background
// stays empty (no album/artist flash), and on a confirmed miss it falls back
// to the Navidrome artist image. With the scraper off, the Navidrome artist
// image is the background straight away.
const externalEnabled = useThemeStore((s) => s.externalArtworkEnabled);
const fanart = useArtistFanart(currentTrack?.artistId, {
artistName: currentTrack?.artist,
albumTitle: currentTrack?.album,
});
const artistCoverRef =
useArtistCoverRef(currentTrack?.artistId, undefined, undefined, { libraryResolve: false }) ??
undefined;
const artistImage = usePlaybackCoverArt(artistCoverRef, 2000, { fullRes: true });
const artistImgUrl = useCachedUrl(artistImage.src, artistImage.cacheKey, true);
const bgUrl = externalEnabled
? fanart.src || (fanart.pending ? '' : artistImgUrl)
: artistImgUrl;
const nextTrack = useQueueTrackAt(queueIndex + 1);
@@ -103,10 +148,8 @@ export default function FullscreenPlayerStatic({ onClose }: Props) {
data-idle={isIdle}
onMouseMove={handleMouseMove}
>
{/* Static sharp background — no blur, no animation */}
{bgUrl
? <img className="fsp-bg" src={bgUrl} alt="" aria-hidden="true" draggable={false} />
: <div className="fsp-bg fsp-bg--empty" aria-hidden="true" />}
{/* Sharp background — no blur; eases in once its pixels are loaded. */}
<FsBackground key={bgUrl} url={bgUrl} />
<div className="fsp-scrim" aria-hidden="true" />
<div className="fsp-vignette" aria-hidden="true" />
+60 -1
View File
@@ -1,14 +1,17 @@
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Info, Sparkles, Wifi } from 'lucide-react';
import { AlertTriangle, Check, Image as ImageIcon, Info, Sparkles, Wifi } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import { useThemeStore } from '../../store/themeStore';
import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
import { MusicNetworkSection } from './musicNetwork/MusicNetworkSection';
import { purgeExternalArtworkAllServers } from '../../api/coverCache';
export function IntegrationsTab() {
const { t } = useTranslation();
const auth = useAuthStore();
const theme = useThemeStore();
return (
<>
@@ -137,6 +140,62 @@ export function IntegrationsTab() {
</div>
</SettingsSubSection>
{/* External artist artwork (fanart.tv) */}
<SettingsSubSection
title={t('settings.externalArtwork')}
icon={<ImageIcon size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsToggle
desc={t('settings.externalArtworkDesc')}
note={t('settings.externalArtworkNote')}
ariaLabel={t('settings.externalArtwork')}
checked={theme.externalArtworkEnabled}
onChange={v => {
theme.setExternalArtworkEnabled(v);
// Opt-out: purge the fetched external images + lookup rows so
// turning the scraper off actually removes the third-party data,
// not just hides it (design-review §9/§12/B.4).
if (!v) void purgeExternalArtworkAllServers();
}}
/>
</SettingsGroup>
{theme.externalArtworkEnabled && (
<SettingsGroup
title={t('settings.externalArtworkByokTitle')}
desc={t('settings.externalArtworkByokDesc')}
>
<div className="form-group">
<input
className="input"
type="password"
value={theme.externalArtworkByok}
onChange={e => theme.setExternalArtworkByok(e.target.value)}
placeholder="fanart.tv personal API key"
spellCheck={false}
autoComplete="off"
/>
{theme.externalArtworkByok.trim() && (
<div
style={{
fontSize: 12,
color: 'var(--text-muted)',
marginTop: 6,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<Check size={13} /> {t('settings.externalArtworkByokSaved')}
</div>
)}
</div>
</SettingsGroup>
)}
</div>
</SettingsSubSection>
{/* Now-Playing Share (Navidrome) */}
<SettingsSubSection
title={t('settings.nowPlayingEnabled')}
+1
View File
@@ -377,6 +377,7 @@ const CONTRIBUTOR_ENTRIES = [
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import, free-form community themes (safety floor + state-reactive styling), and per-theme download counts with popularity/newest/name sorting; 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012, #1013, #1014, #1015, #1016, #1018, #1020, #1036)',
'AutoDJ as a standalone playback mode — own toolbar button and audio-settings entry, mutually exclusive with Crossfade and Gapless via a shared transition helper, combined playlist save/load submenu, and boxed playback settings sections (PR #1124)',
'Settings refactor — reusable boxed panels (SettingsGroup/SettingsToggle) rolled across every tab, Normalization and Track transitions promoted to their own Audio categories, a unified Queue Settings group on Personalisation, and the Audio Output Device category gated out on macOS (PR #1126, #1130)',
'Artist artwork from fanart.tv — opt-in fullscreen background + artist-header banner, off by default (PR #1137)',
],
},
{
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import { artistCoverRef } from './ref';
import { coverDiskUrl } from './diskSrcCache';
import { coverCacheEnsure } from '../api/coverCache';
import { useThemeStore } from '../store/themeStore';
/**
* Resolve an external fanart.tv artist image to a webview-loadable asset URL for
* the given surface (`fanart` = 16:9 background, `banner` = wide header strip).
*
* Returns `{ src, pending }`: `src` is `''` while resolving, when the toggle is
* off, or when no image of that kind exists; `pending` is `true` only while the
* ensure is in flight. `pending` lets callers tell "still fetching" (hold back
* a fallback) apart from "resolved, no image" (fall back now).
*
* Deliberately bypasses the shared cover peek / disk-src cache: each surface has
* its own `{tier}-{surface}.webp`, and `cover_cache_ensure` already peeks that
* surface first and returns the cached path on a hit. All MBID resolution +
* caching lives Rust-side; this hook just kicks the ensure and shows the path it
* hands back. The cache is shared across callers, so e.g. the artist-detail
* header and the fullscreen player warm each other's images.
*/
type ArtistImageCtx = { artistName?: string; albumTitle?: string };
export type ArtistImage = { src: string; pending: boolean };
function useArtistExternalImage(
artistId: string | null | undefined,
surface: 'fanart' | 'banner',
ctx?: ArtistImageCtx,
): ArtistImage {
const enabled = useThemeStore((s) => s.externalArtworkEnabled);
const [image, setImage] = useState<ArtistImage>({ src: '', pending: false });
const artistName = ctx?.artistName;
const albumTitle = ctx?.albumTitle;
useEffect(() => {
if (!enabled || !artistId) {
// Nothing will resolve — not pending, so callers fall back immediately.
setImage({ src: '', pending: false });
return;
}
// Reset + mark pending so a previous artist's image never lingers and
// callers hold their fallback until this resolves.
setImage({ src: '', pending: true });
let cancelled = false;
const ref = artistCoverRef(artistId);
void coverCacheEnsure(ref, 2000, 'high', { surfaceKind: surface, artistName, albumTitle })
.then((res) => {
if (!cancelled)
setImage({ src: res.hit && res.path ? coverDiskUrl(res.path) : '', pending: false });
})
.catch(() => {
if (!cancelled) setImage({ src: '', pending: false });
});
return () => {
cancelled = true;
};
}, [enabled, artistId, surface, artistName, albumTitle]);
return image;
}
/** fanart.tv 16:9 `artistbackground` (fullscreen player background). */
export function useArtistFanart(
artistId: string | null | undefined,
ctx?: ArtistImageCtx,
): ArtistImage {
return useArtistExternalImage(artistId, 'fanart', ctx);
}
/** fanart.tv wide `musicbanner` (artist-detail header strip). */
export function useArtistBanner(
artistId: string | null | undefined,
ctx?: ArtistImageCtx,
): ArtistImage {
return useArtistExternalImage(artistId, 'banner', ctx);
}
-34
View File
@@ -1,34 +0,0 @@
import { useEffect, useState } from 'react';
import { getArtistInfo } from '../api/subsonicArtists';
/** Fetches the large artist image for the given artist id, returning '' until
* the request resolves (or when there is no artist id). Falls through silently
* on network failures — the caller should layer a cover-art fallback on top.
*
* Navidrome / Subsonic backends often return a `largeImageUrl` that 404s when
* the artist has no scraped image (Last.fm placeholder), so the URL is
* preflighted via an Image() probe and only exposed when it actually loads.
* Otherwise the caller would render a broken-img glyph (`?`) on top of the
* fullscreen background — see zunoz report on the Psysonic Discord. */
export function useFsArtistPortrait(artistId: string | undefined): string {
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
useEffect(() => {
setArtistBgUrl('');
if (!artistId) return;
let cancelled = false;
let probe: HTMLImageElement | null = null;
getArtistInfo(artistId).then(info => {
if (cancelled || !info.largeImageUrl) return;
const url = info.largeImageUrl;
probe = new Image();
probe.onload = () => { if (!cancelled) setArtistBgUrl(url); };
probe.onerror = () => { /* leave empty so caller falls back to cover */ };
probe.src = url;
}).catch(() => {});
return () => {
cancelled = true;
if (probe) probe.onload = probe.onerror = null;
};
}, [artistId]);
return artistBgUrl;
}
+6
View File
@@ -269,6 +269,12 @@ export const settings = {
nowPlayingEnabled: 'Im Livefenster anzeigen',
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
nowPlayingPluginNote: 'Notwendig bei Verwendung des offiziellen Navidrome-Discord-Rich-Presence-Plugins.',
externalArtwork: 'Externe Artwork Scraper',
externalArtworkDesc: 'Lädt hochauflösende Künstler-Hintergründe von fanart.tv für den Vollbild-Player und Künstlerseiten. Standardmäßig aus.',
externalArtworkNote: 'Kontaktiert fanart.tv und sendet dabei die MusicBrainz-ID des Künstlers.',
externalArtworkByokTitle: 'Persönlicher API-Schlüssel (BYOK)',
externalArtworkByokDesc: 'Optional. Trag hier deinen eigenen fanart.tv-API-Schlüssel ein — er wird zusätzlich zum App-Schlüssel gesendet.',
externalArtworkByokSaved: 'Gespeichert — wird bei aktivem Scraper mit dem App-Schlüssel gesendet.',
enableBandsintown: 'Bandsintown-Tourdaten',
enableBandsintownDesc: 'Zeigt anstehende Konzerte des aktuellen Künstlers im Info-Tab. Daten werden über die öffentliche Bandsintown-API abgerufen.',
lyricsServerFirst: 'Server-Lyrics bevorzugen',
+6
View File
@@ -272,6 +272,12 @@ export const settings = {
nowPlayingEnabled: 'Show in Now Playing',
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
nowPlayingPluginNote: 'Required when using the official Navidrome Discord Rich Presence plugin.',
externalArtwork: 'External Artwork Scraper',
externalArtworkDesc: 'Fetch high-resolution artist backgrounds from fanart.tv for the fullscreen player and artist pages. Off by default.',
externalArtworkNote: "Contacts fanart.tv, sending the artist's MusicBrainz id.",
externalArtworkByokTitle: 'Personal API key (BYOK)',
externalArtworkByokDesc: 'Optional. Enter your own fanart.tv personal API key here — it is sent in addition to the app key.',
externalArtworkByokSaved: 'Saved — sent with the app key while the scraper is on.',
enableBandsintown: 'Bandsintown tour dates',
enableBandsintownDesc: 'Show upcoming concerts for the current artist in the Info tab. Data is fetched from the public Bandsintown API.',
lyricsServerFirst: 'Prefer server lyrics',
+6
View File
@@ -268,6 +268,12 @@ export const settings = {
nowPlayingEnabled: 'Mostrar en Reproduciendo Ahora',
nowPlayingEnabledDesc: 'Transmite tu pista actual a la vista de oyentes en vivo del servidor. Desactiva para dejar de enviar datos de reproducción.',
nowPlayingPluginNote: 'Necesario al usar el plugin oficial de Discord Rich Presence de Navidrome.',
externalArtwork: 'Buscador de imágenes externo',
externalArtworkDesc: 'Obtén fondos de artista en alta resolución desde fanart.tv para el reproductor a pantalla completa y las páginas de artista. Desactivado por defecto.',
externalArtworkNote: 'Contacta con fanart.tv enviando el ID de MusicBrainz del artista.',
externalArtworkByokTitle: 'Clave API personal (BYOK)',
externalArtworkByokDesc: 'Opcional. Introduce aquí tu propia clave API personal de fanart.tv — se envía junto con la clave de la app.',
externalArtworkByokSaved: 'Guardado — se envía con la clave de la app mientras el scraper está activo.',
enableBandsintown: 'Fechas de gira de Bandsintown',
enableBandsintownDesc: 'Muestra próximos conciertos del artista actual en la pestaña Info. Los datos se obtienen de la API pública de Bandsintown.',
lyricsServerFirst: 'Preferir letras del servidor',
+6
View File
@@ -256,6 +256,12 @@ export const settings = {
nowPlayingEnabled: 'Afficher dans la fenêtre live',
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
nowPlayingPluginNote: 'Requis lors de l\'utilisation du plugin officiel Discord Rich Presence de Navidrome.',
externalArtwork: "Scraper d'illustrations externe",
externalArtworkDesc: "Récupère des arrière-plans d'artiste en haute résolution depuis fanart.tv pour le lecteur plein écran et les pages d'artiste. Désactivé par défaut.",
externalArtworkNote: "Contacte fanart.tv en envoyant l'identifiant MusicBrainz de l'artiste.",
externalArtworkByokTitle: 'Clé API personnelle (BYOK)',
externalArtworkByokDesc: "Facultatif. Saisissez ici votre propre clé API personnelle fanart.tv — elle est envoyée en plus de la clé de l'application.",
externalArtworkByokSaved: "Enregistré — envoyé avec la clé de l'application lorsque le scraper est actif.",
enableBandsintown: 'Dates de tournée Bandsintown',
enableBandsintownDesc: 'Affiche les concerts à venir de l\'artiste actuel dans l\'onglet Info. Les données proviennent de l\'API publique Bandsintown.',
lyricsServerFirst: 'Préférer les paroles du serveur',
+6
View File
@@ -255,6 +255,12 @@ export const settings = {
nowPlayingEnabled: 'Vis i "Nå spiller"',
nowPlayingEnabledDesc: 'Send sporet som spilles av til tjenerens live-lyttervisning. Deaktiver for å stoppe sending av avspillingsdata.',
nowPlayingPluginNote: 'Nødvendig ved bruk av det offisielle Navidrome Discord Rich Presence-tillegget.',
externalArtwork: 'Ekstern bildehenter',
externalArtworkDesc: 'Hent høyoppløselige artistbakgrunner fra fanart.tv for fullskjermspilleren og artistsider. Av som standard.',
externalArtworkNote: 'Kontakter fanart.tv og sender artistens MusicBrainz-ID.',
externalArtworkByokTitle: 'Personlig API-nøkkel (BYOK)',
externalArtworkByokDesc: 'Valgfritt. Skriv inn din egen personlige fanart.tv-API-nøkkel her — den sendes i tillegg til app-nøkkelen.',
externalArtworkByokSaved: 'Lagret — sendes med app-nøkkelen mens skraperen er på.',
enableBandsintown: 'Bandsintown-turnédatoer',
enableBandsintownDesc: 'Vis kommende konserter for gjeldende artist i Info-fanen. Data hentes fra det offentlige Bandsintown-API-et.',
lyricsServerFirst: 'Foretrekk server-sangtekst',
+6
View File
@@ -256,6 +256,12 @@ export const settings = {
nowPlayingEnabled: 'Weergeven in live-venster',
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
nowPlayingPluginNote: 'Vereist bij gebruik van de officiële Navidrome Discord Rich Presence-plug-in.',
externalArtwork: 'Externe artwork-scraper',
externalArtworkDesc: "Haal hogeresolutie artiestachtergronden van fanart.tv op voor de schermvullende speler en artiestpagina's. Standaard uit.",
externalArtworkNote: 'Neemt contact op met fanart.tv en verstuurt de MusicBrainz-id van de artiest.',
externalArtworkByokTitle: 'Persoonlijke API-sleutel (BYOK)',
externalArtworkByokDesc: 'Optioneel. Voer hier je eigen persoonlijke fanart.tv-API-sleutel in — deze wordt naast de app-sleutel verstuurd.',
externalArtworkByokSaved: 'Opgeslagen — wordt met de app-sleutel verstuurd terwijl de scraper aan staat.',
enableBandsintown: 'Bandsintown-tourdata',
enableBandsintownDesc: 'Toon aankomende concerten van de huidige artiest in het Info-tabblad. Gegevens komen van de openbare Bandsintown-API.',
lyricsServerFirst: 'Server-songtekst voorrang geven',
+6
View File
@@ -271,6 +271,12 @@ export const settings = {
nowPlayingEnabled: 'Se afișează în Now Playing',
nowPlayingEnabledDesc: 'Difuzează piesa redată curent către vizualizatorul de ascultare live a serverului. Dezactivează pentru a opri trimiterea datelor de redare.',
nowPlayingPluginNote: 'Necesar la utilizarea pluginului oficial Discord Rich Presence de la Navidrome.',
externalArtwork: 'Scraper de imagini extern',
externalArtworkDesc: 'Preia fundaluri de artist la rezoluție înaltă de pe fanart.tv pentru playerul pe tot ecranul și paginile de artist. Dezactivat implicit.',
externalArtworkNote: 'Contactează fanart.tv, trimițând ID-ul MusicBrainz al artistului.',
externalArtworkByokTitle: 'Cheie API personală (BYOK)',
externalArtworkByokDesc: 'Opțional. Introdu aici propria ta cheie API personală fanart.tv — este trimisă pe lângă cheia aplicației.',
externalArtworkByokSaved: 'Salvat — trimis cu cheia aplicației cât timp scraperul este activ.',
enableBandsintown: 'Date de turneu Bandsintown',
enableBandsintownDesc: 'Arată concertele următoare pentru artisul curent în tab-ul Informații. Datele sunt preluate din API-ul public Bandsintown.',
lyricsServerFirst: 'Preferă versurile de pe server',
+6
View File
@@ -276,6 +276,12 @@ export const settings = {
nowPlayingEnabledDesc:
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
nowPlayingPluginNote: 'Необходимо при использовании официального плагина Discord Rich Presence для Navidrome.',
externalArtwork: 'Внешний сборщик обложек',
externalArtworkDesc: 'Загружает фоновые изображения исполнителей в высоком разрешении с fanart.tv для полноэкранного плеера и страниц исполнителей. По умолчанию выключено.',
externalArtworkNote: 'Обращается к fanart.tv, передавая MusicBrainz-идентификатор исполнителя.',
externalArtworkByokTitle: 'Личный API-ключ (BYOK)',
externalArtworkByokDesc: 'Необязательно. Введите здесь свой личный API-ключ fanart.tv — он отправляется вместе с ключом приложения.',
externalArtworkByokSaved: 'Сохранено — отправляется с ключом приложения, пока скрапер включён.',
enableBandsintown: 'Даты туров Bandsintown',
enableBandsintownDesc: 'Показывает предстоящие концерты текущего исполнителя на вкладке «Инфо». Данные берутся из публичного API Bandsintown.',
lyricsServerFirst: 'Предпочитать тексты с сервера',
+6
View File
@@ -255,6 +255,12 @@ export const settings = {
nowPlayingEnabled: '在实时窗口中显示',
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
nowPlayingPluginNote: '使用官方 Navidrome Discord Rich Presence 插件时必需。',
externalArtwork: '外部封面采集器',
externalArtworkDesc: '从 fanart.tv 获取高分辨率的艺术家背景图,用于全屏播放器和艺术家页面。默认关闭。',
externalArtworkNote: '会联系 fanart.tv,并发送艺术家的 MusicBrainz ID。',
externalArtworkByokTitle: '个人 API 密钥(BYOK',
externalArtworkByokDesc: '可选。在此输入你自己的 fanart.tv 个人 API 密钥——它会与应用密钥一同发送。',
externalArtworkByokSaved: '已保存——抓取器开启时随应用密钥一同发送。',
enableBandsintown: 'Bandsintown 巡演日期',
enableBandsintownDesc: '在信息标签页中显示当前艺术家的即将到来的演出。数据通过公开的 Bandsintown API 获取。',
lyricsServerFirst: '优先使用服务器歌词',
+10
View File
@@ -43,6 +43,12 @@ interface ThemeState {
setExpandReplayGain: (v: boolean) => void;
floatingPlayerBar: boolean;
setFloatingPlayerBar: (v: boolean) => void;
/** Master toggle for external artist artwork (fanart.tv). Off by default (§20). */
externalArtworkEnabled: boolean;
setExternalArtworkEnabled: (v: boolean) => void;
/** Optional personal fanart.tv API key (BYOK) — sent in addition to the app key (§22). */
externalArtworkByok: string;
setExternalArtworkByok: (v: string) => void;
}
export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string {
@@ -86,6 +92,10 @@ export const useThemeStore = create<ThemeState>()(
setExpandReplayGain: (v) => set({ expandReplayGain: v }),
floatingPlayerBar: false,
setFloatingPlayerBar: (v) => set({ floatingPlayerBar: v }),
externalArtworkEnabled: false,
setExternalArtworkEnabled: (v) => set({ externalArtworkEnabled: v }),
externalArtworkByok: '',
setExternalArtworkByok: (v) => set({ externalArtworkByok: v }),
}),
{
name: 'psysonic_theme',
@@ -27,9 +27,19 @@
object-fit: cover;
/* Bias toward the upper third so faces survive a 16:9 crop of square art. */
object-position: center 30%;
/* Eases in once the image has loaded (toggled via `.is-loaded`), so a new
background fades up from the empty backdrop instead of hard-cutting. */
opacity: 0;
transition: opacity 0.4s ease;
}
.fsp-bg.is-loaded {
opacity: 1;
}
.fsp-bg--empty {
/* The empty/loading backdrop is shown as-is (no image to fade in). */
opacity: 1;
background: radial-gradient(circle at 50% 35%, #1a1a2e, #06060e 70%);
}
@@ -142,6 +142,15 @@
margin-bottom: 2rem;
}
/* The album page wraps its header in a flush `.album-detail`; the artist page
wraps in a padded `.content-body` (padding: var(--space-6)). This cancels that
padding so the shared `.album-detail-header` reaches the container edges
(top + sides) exactly like the album header full-bleed, no spacing. */
.album-detail-header.artist-detail-bleed {
margin-top: calc(-1 * var(--space-6));
margin-inline: calc(-1 * var(--space-6));
}
.artist-detail-avatar {
width: 160px;
height: 160px;