mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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<String>,
|
||||
pub mbid_source: Option<String>,
|
||||
pub status: String,
|
||||
pub provider: Option<String>,
|
||||
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<Option<ArtistArtworkRow>, 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<usize, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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")
|
||||
|
||||
@@ -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/<mbid>` → 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<Option<String>, 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<Option<String>, 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<MbResolution, String> {
|
||||
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<String, String> {
|
||||
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<Option<String>, 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));
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
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<Arc<psysonic_library::store::LibraryStore>>,
|
||||
server_id: &str,
|
||||
artist_id: &str,
|
||||
surface: &str,
|
||||
) -> Option<psysonic_library::artist_artwork::ArtistArtworkRow> {
|
||||
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<Arc<psysonic_library::store::LibraryStore>>,
|
||||
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<Semaphore>,
|
||||
musicbrainz_sem: &Arc<Semaphore>,
|
||||
requested: u32,
|
||||
surface: &str,
|
||||
) -> Option<PathBuf> {
|
||||
// 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<Arc<psysonic_library::store::LibraryStore>> =
|
||||
app.try_state::<LibraryRuntime>().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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// Album title currently in context (fullscreen playback) — disambiguates
|
||||
/// the name→MusicBrainz query (§19).
|
||||
#[serde(default)]
|
||||
pub album_title: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<Semaphore>,
|
||||
pub cover_cpu_ui_sem: Arc<Semaphore>,
|
||||
pub cover_cpu_backfill_sem: Arc<Semaphore>,
|
||||
/// External-provider (fanart.tv) HTTP lane — separate from `http_sem` so
|
||||
/// external fetches never starve Navidrome cover / getArtistInfo2 (§26).
|
||||
pub fanart_http_sem: Arc<Semaphore>,
|
||||
/// 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<Semaphore>,
|
||||
/// 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<CoverCacheEnsureResult, String> {
|
||||
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<u32> = if args.library_bulk {
|
||||
@@ -824,6 +888,7 @@ fn peek_tier_path(dir: &Path, want: u32) -> Option<PathBuf> {
|
||||
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::<LibraryRuntime>() {
|
||||
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::<Arc<CoverBackfillWorker>>() {
|
||||
@@ -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::<LibraryRuntime>() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+52
-2
@@ -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" />
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Предпочитать тексты с сервера',
|
||||
|
||||
@@ -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: '优先使用服务器歌词',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user