Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions docs/features/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,15 @@ The pencil overlay on the artist photo in [`ArtistDetailView`](../../src/compone
- **Remove image** → `clear_artist_artwork` sets `artist.artwork_id = NULL` so the next render falls back through the resolution chain (Deezer cache → live fetch).

Both `set_artist_artwork_from_*` overwrite `artwork_id` unconditionally — an explicit user pick beats any automatic resolution.

### Wide artist fanart (hero)

Everything above carries the **square** artist photo. The [artist hero](ui.md#artist-hero) needs a **wide** one, and the only source in the stack that has one is TheAudioDB (issue #482).

[`metadata::theaudiodb`](../../src-tauri/crates/core/src/metadata/theaudiodb.rs) already queried `search.php` for multi-language bios; the same response carries `strArtistFanart` (+ `2/3/4`), `strArtistWideThumb` and `strArtistBanner`. `TheAudioDbClient::artist_info` now returns bio **and** `fanart_url` from one lookup, picking the first non-blank image widest-and-cleanest first (fanart → alternates → wide thumb → logo banner last, since baked-in text can clash with the header copy). It returns `Some` for any name match even with neither bio nor fanart, so the caller can cache the "looked, nothing there" outcome.

[`enrich_artist_deezer`](../../src-tauri/crates/app/src/commands/deezer.rs) calls it **independently of the `metadata.bio_source` setting**: Last.fm has no equivalent image, so gating the fanart on the bio source would leave every Last.fm user with no hero at all. One request serves both consumers (the bio half is used only when TheAudioDB *is* the selected source) — TheAudioDB's shared free key is rate-limited, so it's one call, cached hard. The URL is downloaded through the usual `metadata_artwork::download_and_cache` (BLAKE3-addressed, shared across profiles) and kept at **full resolution** — no `_1x` / `_2x` tier, downscaling a full-bleed banner would only soften it. Offline mode short-circuits before any of this, and the blurred-photo tier still works.

Cached in `app.metadata_artist` next to the picture pair: `background_url` + `background_hash`, plus **`background_fetched_at`** — the "we already looked" marker (migration `20260802120000_metadata_artist_background.sql`). Without it a NULL hash can't be told apart from "never queried", so every artist without fanart would re-hit a rate-limited API on each page visit. It is stamped whenever the API was *reached* (match or not) and left NULL on a transport error, so a network blip retries instead of caching as "this artist has no fanart" for the row's whole 30-day TTL. Rows written before the migration have NULL there and are treated as a background cache miss on their next refresh, which backfills them once.

Both `get_artist_detail` (first paint, straight from the cache) and `enrich_artist_deezer` (refresh) return `background_url` / `background_path`.
14 changes: 14 additions & 0 deletions docs/features/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ It sits one rung below the motion cover in the backdrop precedence — **Canvas

**Toggle + guardrails** — a per-profile preference [`useCoverSlideshow`](../../src/hooks/useCoverSlideshow.ts) (`ui.cover_slideshow`, **default OFF**), toggled in Settings → Appearance via [`CoverSlideshowCard`](../../src/components/views/settings/CoverSlideshowCard.tsx); the write machinery (serialized writes, profile-switch guards, rollback, broadcast) mirrors [`useScrollLongTitles`](../../src/hooks/useScrollLongTitles.ts). `prefers-reduced-motion` suppresses the alternation, and a missing artist photo falls back to the static cover — so the feature is purely additive. The artist image is resolved through [`useArtistImage`](../../src/hooks/useArtistImage.ts) at **`"full"`** resolution (matching the artist detail page — a 1x thumbnail would upscale blurry in the large cover slot), and the enrichment fetch is gated on the toggle so it costs nothing while off. i18n under `settings.coverSlideshow.*`.

## Artist hero

[`ArtistHeroBackdrop`](../../src/components/common/ArtistHeroBackdrop.tsx) paints a **full-bleed backdrop behind the artist detail header** (issue #482), the Spotify artist-banner look — replacing the flat surface that only carried a circular avatar + name. Mounted by [`ArtistDetailView`](../../src/components/views/ArtistDetailView.tsx), which wraps its header in a `-mx-8 -mt-8` block to break out of `<main>`'s `p-8` so the image reaches the column edges (and shrinks with the column when a right panel opens).

**Two image tiers plus a no-image case, in precedence order:**

1. **Real wide fanart** — `strArtistFanart` (or its alternates / `strArtistWideThumb` / `strArtistBanner`) from TheAudioDB, downloaded into the shared `metadata_artwork/` cache. Shown nearly crisp: `blur(2px)` only, enough to keep JPEG artefacts from crawling under the header copy. See [the backend pipeline](library.md#wide-artist-fanart-hero).
2. **The square artist photo** — Deezer picture or a local `artist.jpg`, heavily blurred + upscaled (`blur(56px) saturate(190%)`, `scale(1.35)`), the same colour-field treatment [`SkinAmbientBackdrop`](../../src/components/layout/SkinAmbientBackdrop.tsx) uses. Always available and **works offline**, which is why it's the universal fallback — a 1:1 image stretched across a banner would be unreadable unblurred.
3. **Nothing** — an artist with no image at all keeps today's flat header.

**Legibility** is not left to the theme: the image always carries a dark scrim (`from-black/85 via-black/60 to-black/35`) and the header copy (eyebrow / name / stats) is forced **white in every theme**, matching Spotify — whose artist header is dark-on-image in light mode too. The secondary buttons swap to a translucent white treatment over the hero. The bottom edge fades out through a **mask** (`linear-gradient(to bottom, black 68%, transparent)`) rather than a hard-coded colour stop, so the hero dissolves into whatever the current theme × skin paints behind it.

**Toggle + guardrails** — per-profile preference [`useArtistHero`](../../src/hooks/useArtistHero.ts) (`ui.artist_hero`, **default ON** — it's a baseline visual, not extra motion), toggled in Settings → Appearance via [`ArtistHeroCard`](../../src/components/views/settings/ArtistHeroCard.tsx); the write machinery (serialized writes, profile-switch guards, rollback, broadcast) mirrors [`useCoverSlideshow`](../../src/hooks/useCoverSlideshow.ts). `prefers-reduced-motion` skips the `artistHeroFadeIn` cross-fade only — the image itself is static, so there is nothing else to suppress. The fanart source is seeded from `get_artist_detail` (metadata cache, first frame) and refined by the later `enrich_artist_deezer` response, which only ever *sets* it: a refresh that comes back empty (offline, TheAudioDB down) must not blank a hero the cache already produced. i18n under `settings.artistHero.*`.

## Mini-player

[`MiniPlayerApp`](../../src/MiniPlayerApp.tsx) + [`MiniPlayer`](../../src/components/views/MiniPlayer.tsx) ship a Spotify-style always-on-top widget. Launched from the picture-in-picture button in the PlayerBar via [`lib/miniPlayer.ts::openMiniPlayer`](../../src/lib/miniPlayer.ts).
Expand Down
15 changes: 15 additions & 0 deletions src-tauri/crates/app/src/commands/browse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,11 @@ pub struct ArtistDetail {
pub fans_count: Option<i64>,
pub bio_short: Option<String>,
pub bio_full: Option<String>,
/// Wide TheAudioDB fanart backing the artist hero (issue #482).
/// Served straight from the metadata cache so the hero paints on the
/// first frame instead of waiting for `enrich_artist_deezer`.
pub background_url: Option<String>,
pub background_path: Option<String>,
pub track_count: i64,
pub album_count: i64,
pub albums: Vec<ArtistAlbumRow>,
Expand All @@ -1228,6 +1233,8 @@ struct ArtistDetailRaw {
fans_count: Option<i64>,
bio_short: Option<String>,
bio_full: Option<String>,
background_url: Option<String>,
background_hash: Option<String>,
track_count: i64,
album_count: i64,
}
Expand Down Expand Up @@ -1274,6 +1281,8 @@ pub async fn get_artist_detail(
da.fans_count AS fans_count,
da.bio_short AS bio_short,
da.bio_full AS bio_full,
da.background_url AS background_url,
da.background_hash AS background_hash,
COUNT(DISTINCT t.id) AS track_count,
COUNT(DISTINCT t.album_id) AS album_count
FROM artist ar
Expand Down Expand Up @@ -1361,6 +1370,10 @@ pub async fn get_artist_detail(
Some(h) => crate::thumbnails::thumbnail_paths_for(metadata_dir, h),
None => (None, None),
};
let background_path = header
.background_hash
.as_deref()
.and_then(|h| crate::metadata_artwork::existing_path(metadata_dir, h));

Ok(ArtistDetail {
id: header.id,
Expand All @@ -1375,6 +1388,8 @@ pub async fn get_artist_detail(
fans_count: header.fans_count,
bio_short: header.bio_short,
bio_full: header.bio_full,
background_url: header.background_url,
background_path,
track_count: header.track_count,
album_count: header.album_count,
albums,
Expand Down
105 changes: 86 additions & 19 deletions src-tauri/crates/app/src/commands/deezer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,13 @@ pub struct DeezerArtistEnrichment {
pub bio_short: Option<String>,
/// Full biography from Last.fm. HTML stripped.
pub bio_full: Option<String>,
/// Remote TheAudioDB URL of the wide artist fanart (issue #482) —
/// fallback when the local download failed.
pub background_url: Option<String>,
/// Absolute filesystem path to the locally-cached fanart. Feeds the
/// artist hero; `None` means the artist has no wide image and the
/// frontend falls back to blurring the square photo.
pub background_path: Option<String>,
}

impl DeezerArtistEnrichment {
Expand All @@ -276,6 +283,8 @@ impl DeezerArtistEnrichment {
fans_count: None,
bio_short: None,
bio_full: None,
background_url: None,
background_path: None,
}
}
}
Expand Down Expand Up @@ -367,10 +376,14 @@ async fn enrich_artist_deezer_inner(
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<i64>,
i64,
)> = sqlx::query_as(
"SELECT picture_url, picture_hash, fans_count, bio_short, bio_full,
bio_source, bio_language, expires_at
bio_source, bio_language,
background_url, background_hash, background_fetched_at, expires_at
FROM app.metadata_artist WHERE deezer_id = ?",
)
.bind(did)
Expand All @@ -385,6 +398,9 @@ async fn enrich_artist_deezer_inner(
bio_full,
cached_bio_source,
cached_bio_language,
background_url,
background_hash,
background_fetched_at,
expires_at,
)) = cached
{
Expand All @@ -394,7 +410,13 @@ async fn enrich_artist_deezer_inner(
let bio_fresh = BioSource::parse(cached_bio_source.as_deref()) == active_source
&& (active_source != BioSource::TheAudioDb
|| cached_bio_language.as_deref() == Some(active_lang.as_str()));
if expires_at > now && bio_fresh {
// A row written before issue #482 never looked for fanart —
// `background_fetched_at IS NULL` is the marker, and a NULL
// hash alone can't say it apart from "looked, found nothing".
// Falling through backfills it once, then this stays true
// for the rest of the row's TTL.
let background_fresh = background_fetched_at.is_some();
if expires_at > now && bio_fresh && background_fresh {
// A row cached before #406 may hold a Deezer placeholder
// URL (and a grey-blob hash). Drop both so we surface the
// initial-letter avatar instead of the grey box; the row's
Expand All @@ -416,6 +438,9 @@ async fn enrich_artist_deezer_inner(
Some(h) => crate::thumbnails::thumbnail_paths_for(&artwork_dir, h),
None => (None, None),
};
let background_path = background_hash
.as_deref()
.and_then(|h| metadata_artwork::existing_path(&artwork_dir, h));
return Ok(DeezerArtistEnrichment {
deezer_id: Some(did),
picture_url,
Expand All @@ -425,6 +450,8 @@ async fn enrich_artist_deezer_inner(
fans_count,
bio_short,
bio_full,
background_url,
background_path,
});
}
}
Expand Down Expand Up @@ -473,7 +500,30 @@ async fn enrich_artist_deezer_inner(
return Ok(DeezerArtistEnrichment::empty());
};

// 4. Fetch the bio from the selected source (issue #295). Network
// 4. TheAudioDB lookup — one call, two consumers. The wide fanart
// backing the artist hero (issue #482) is fetched whatever the
// selected bio source is: Last.fm has no equivalent image, so
// gating this on `bio_source` would leave every Last.fm user
// with no hero at all. The bio half of the same response is only
// used when TheAudioDB IS the selected source — one request
// instead of two, which matters on their rate-limited free key.
let audiodb_result = TheAudioDbClient::new()
.artist_info(&artist_name, &active_lang)
.await;
// A *reached* API — match or not — is what licenses stamping
// `background_fetched_at` below. A transport error leaves it NULL so
// the next visit retries instead of caching a network blip as "this
// artist has no fanart" for the whole 30-day TTL.
let audiodb_reached = audiodb_result.is_ok();
let audiodb = match audiodb_result {
Ok(info) => info,
Err(err) => {
tracing::warn!(?err, "TheAudioDB artist_info failed");
None
}
};

// 5. Fetch the bio from the selected source (issue #295). Network
// failures and missing matches are non-fatal — we still persist
// the Deezer portion so the next refresh doesn't spam the
// network. The source/language we used is stored alongside so a
Expand All @@ -493,22 +543,16 @@ async fn enrich_artist_deezer_inner(
}
None => (None, None),
},
BioSource::TheAudioDb => {
let client = TheAudioDbClient::new();
match client.artist_bio(&artist_name, &active_lang).await {
Ok(Some(info)) => (info.bio_short, info.bio_full),
Ok(None) => (None, None),
Err(err) => {
tracing::warn!(?err, "TheAudioDB artist_bio failed");
(None, None)
}
}
}
BioSource::TheAudioDb => match audiodb.as_ref() {
Some(info) => (info.bio_short.clone(), info.bio_full.clone()),
None => (None, None),
},
};

let picture_url = hit.best_picture();
let background_url = audiodb.and_then(|info| info.fanart_url);

// 5. Download artwork into the shared cache (best-effort).
// 6. Download artwork into the shared cache (best-effort).
let picture_hash = match picture_url.as_deref() {
Some(url) => metadata_artwork::download_and_cache(url, &artwork_dir).await,
None => None,
Expand All @@ -520,8 +564,19 @@ async fn enrich_artist_deezer_inner(
Some(h) => crate::thumbnails::thumbnail_paths_for(&artwork_dir, h),
None => (None, None),
};
// The hero paints the fanart full-bleed behind the header, so it's
// the one image we deliberately keep at full resolution — hence the
// `_full_res` variant, which skips the `_1x` / `_2x` thumbnail job:
// downscaling would only soften the crop, and nothing reads the tiers.
let background_hash = match background_url.as_deref() {
Some(url) => metadata_artwork::download_and_cache_full_res(url, &artwork_dir).await,
None => None,
};
let background_path = background_hash
.as_deref()
.and_then(|h| metadata_artwork::existing_path(&artwork_dir, h));

// 6. Upsert into the metadata cache (Deezer + bio fields land in the
// 7. Upsert into the metadata cache (Deezer + bio fields land in the
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// unified `metadata_artist` table in app.db so every profile
// shares the same cache). `bio_source` / `bio_language` record
// which provider produced the bio so a later switch invalidates
Expand All @@ -533,8 +588,9 @@ async fn enrich_artist_deezer_inner(
sqlx::query(
"INSERT INTO app.metadata_artist
(deezer_id, name, picture_url, picture_hash, fans_count, bio_short, bio_full,
bio_source, bio_language, fetched_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
bio_source, bio_language, background_url, background_hash, background_fetched_at,
fetched_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(deezer_id) DO UPDATE SET
name = excluded.name,
picture_url = excluded.picture_url,
Expand All @@ -544,6 +600,9 @@ async fn enrich_artist_deezer_inner(
bio_full = excluded.bio_full,
bio_source = excluded.bio_source,
bio_language = excluded.bio_language,
background_url = excluded.background_url,
background_hash = excluded.background_hash,
background_fetched_at = excluded.background_fetched_at,
fetched_at = excluded.fetched_at,
expires_at = excluded.expires_at",
)
Expand All @@ -556,12 +615,18 @@ async fn enrich_artist_deezer_inner(
.bind(bio_full.as_deref())
.bind(active_source.as_str())
.bind(stored_lang)
.bind(background_url.as_deref())
.bind(background_hash.as_deref())
// Stamped even when the lookup came back empty — that's the whole
// point of the column: "we asked, TheAudioDB has nothing". Left NULL
// when the API couldn't be reached at all, so that retries.
.bind(audiodb_reached.then_some(now))
.bind(now)
.bind(expires)
.execute(&pool)
.await?;

// 7. Link deezer_id on the local artist.
// 8. Link deezer_id on the local artist.
if existing_deezer_id.is_none() {
sqlx::query("UPDATE artist SET deezer_id = ? WHERE id = ?")
.bind(hit.id)
Expand All @@ -579,6 +644,8 @@ async fn enrich_artist_deezer_inner(
fans_count: hit.nb_fan,
bio_short,
bio_full,
background_url,
background_path,
})
}

Expand Down
Loading