From f7d7d503bf9540ad10b00fecbd083f9228e90a70 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 25 May 2026 13:58:44 +0200 Subject: [PATCH 1/3] feat(similar-artists): backfill Deezer pictures when Last.fm wins the cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last.fm's artist.getSimilar has been returning the same generic star placeholder image URL for every result since they retired their artist- image endpoint in 2019. With a Last.fm key configured, the similar- artists carousel was therefore showing a sea of grey stars for every entry that wasn't already in the user's library — only in-library hits got a picture, courtesy of the existing profile-DB Deezer cache. Users without a Last.fm key got a better visual outcome because the cascade fell through to Deezer's /artist/{id}/related, which returns real photo URLs. Fix the asymmetry by routing the raw cascade output through a new enrich_with_deezer_pictures helper before responding. It joins against the cross-profile app.metadata_artist cache by canonical name, fans out parallel Deezer search_artist calls for cache misses (≤ 12 in flight), persists the new rows + downloads the image into the shared metadata_artwork cache, and merges everything into a per-name HashMap the final DTO mapping consumes. Result: same UX whether Last.fm is configured or not, plus a permanent disk cache so subsequent loads are network-free. No-op when offline mode is on (stale metadata cache only) — matches the existing offline behaviour of every other Deezer-enrichment path. --- CLAUDE.md | 2 +- docs/features/integrations.md | 2 + src-tauri/src/commands/similar.rs | 149 +++++++++++++++++++++++++++++- 3 files changed, 151 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d41deae..eb8ba5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,7 @@ Playlist sort dropdown (custom / title / artist / album / recently added / durat ### Integrations ([`docs/features/integrations.md`](docs/features/integrations.md)) -Deezer enrichment (pictures, covers, fans — cached 30 days in `deezer_artist` / `deezer_album` in `app.db`, hashes point into shared `metadata_artwork/.jpg` so artwork renders offline) · Last.fm (bios, similar artists, scrobbler) · Discord RPC · Native OS track-change toast notifications ([`notifications.rs`](src-tauri/src/notifications.rs) — `tauri-plugin-notification` bridge to Windows Action Center / macOS Notification Center / libnotify, opt-in `app_setting['notifications.track_change']` default OFF) · DLNA / UPnP MediaServer ([`docs/features/dlna.md`](docs/features/dlna.md)). +Deezer enrichment (pictures, covers, fans — cached 30 days in `deezer_artist` / `deezer_album` in `app.db`, hashes point into shared `metadata_artwork/.jpg` so artwork renders offline) · Last.fm (bios, similar artists with Deezer picture backfill — Last.fm's `artist.getSimilar` returns generic star placeholders for every image since their artist-image API was killed in 2019, so [`similar::enrich_with_deezer_pictures`](src-tauri/src/commands/similar.rs) joins the result against `app.metadata_artist` and fans out parallel Deezer `search_artist` calls for any cache miss before responding; scrobbler) · Discord RPC · Native OS track-change toast notifications ([`notifications.rs`](src-tauri/src/notifications.rs) — `tauri-plugin-notification` bridge to Windows Action Center / macOS Notification Center / libnotify, opt-in `app_setting['notifications.track_change']` default OFF) · DLNA / UPnP MediaServer ([`docs/features/dlna.md`](docs/features/dlna.md)). ### Preferences & maintenance diff --git a/docs/features/integrations.md b/docs/features/integrations.md index e1b30af..2c5c45b 100644 --- a/docs/features/integrations.md +++ b/docs/features/integrations.md @@ -41,6 +41,8 @@ The frontend helper `lib/tauri/artwork.ts::resolveRemoteImage` prefers the local Results are cached in `app.lastfm_similar` (30-day TTL, keyed by the source artist's canonical name — same `canonical_name()` routine as the scanner). Each suggestion is augmented at query time with a `library_artist_id` when its canonical name matches a row in the active profile, so the UI can badge it as "in your library" and route the click back to the local artist page. Suggestions outside the library are rendered greyed out and non-interactive — no in-app destination exists for them yet. +**Picture enrichment.** Last.fm's `artist.getSimilar` returns the same generic star placeholder URL for every result (their artist-image endpoint was retired in 2019). To avoid a sea of grey stars when the cascade picks the Last.fm branch, `get_similar_artists` runs the raw list through `enrich_with_deezer_pictures` before responding: it joins against the cross-profile `app.metadata_artist` cache by canonical name, fans out parallel Deezer `search_artist` calls for cache misses (≤ 12 in flight, bounded by the result limit), and writes the new rows back so the picture survives for 30 days. The DTO's `picture_url` is rewritten to the Deezer URL whenever one is available; `picture_path` prefers the in-library hash (set by the existing profile-DB join) before falling back to the freshly cached `metadata_artist.picture_hash`. Skipped silently when offline mode is on — stale cache only. + ### Authenticated (scrobbling) [`scrobbler.rs`](../../src-tauri/src/scrobbler.rs) is the worker thread that drives Last.fm scrobbles: diff --git a/src-tauri/src/commands/similar.rs b/src-tauri/src/commands/similar.rs index e0ffcc0..e55954e 100644 --- a/src-tauri/src/commands/similar.rs +++ b/src-tauri/src/commands/similar.rs @@ -155,19 +155,41 @@ pub async fn get_similar_artists( } } + // 4. Picture enrichment via Deezer. Last.fm's `artist.getSimilar` + // returns the same generic star placeholder URL for every entry + // (their artist-image API was retired in 2019), so without this + // step Last.fm-configured users get a sea of grey stars for + // every similar artist that isn't already in their library. + // This step is a no-op when offline mode is on. + let metadata_map = + enrich_with_deezer_pictures(&pool, &artwork_dir, &canonicals, &raw, now).await; + let out = raw .into_iter() .take(RESULT_LIMIT) .map(|r| { let canon = canonical_name(&r.name); let local = local_map.get(&canon); + let meta = metadata_map.get(&canon); + // Picture-path priority: profile-DB Deezer hash (works + // offline) → cross-profile `metadata_artist` hash (filled + // by the enrichment step above for entries not in the + // library) → no local fallback, UI uses `picture_url`. let picture_path = local .and_then(|(_, hash)| hash.as_deref()) + .or_else(|| meta.and_then(|(_, hash)| hash.as_deref())) .and_then(|h| metadata_artwork::existing_path(&artwork_dir, h)); + // Prefer the Deezer URL over Last.fm's placeholder when + // both are present. Falls back to whatever the upstream + // gave us so a Deezer-fetch failure still surfaces *some* + // remote URL (good enough for the in-library badge case). + let picture_url = meta + .and_then(|(url, _)| url.clone()) + .or(r.picture_url); SimilarArtistDto { name: r.name, match_score: r.match_score, - picture_url: r.picture_url, + picture_url, picture_path, library_artist_id: local.map(|(id, _)| *id), source: r.source, @@ -177,6 +199,131 @@ pub async fn get_similar_artists( Ok(out) } +/// Backfill `app.metadata_artist` for every name in `raw` that we don't +/// already have a non-expired cache row for, then return a +/// `canonical_name → (picture_url, picture_hash)` lookup map ready to +/// be merged into the outgoing DTOs. +/// +/// The cache is the cross-profile `app.metadata_artist` so the work is +/// shared with other features (the `ArtistDetailView` Deezer enrichment, +/// the Wrapped year-in-review, etc.). Cache misses fan out to Deezer +/// `search_artist` in parallel — bounded by `RESULT_LIMIT` so we never +/// fire more than 12 requests per artist-page click. +async fn enrich_with_deezer_pictures( + pool: &SqlitePool, + artwork_dir: &std::path::Path, + canonicals: &[String], + raw: &[RawSimilar], + now: i64, +) -> HashMap, Option)> { + let mut map: HashMap, Option)> = HashMap::new(); + if canonicals.is_empty() || crate::offline::is_offline() { + return map; + } + + // Pull every relevant cached row in one round-trip. `LOWER(TRIM(name))` + // matches `canonical_name` precisely (see `commands::scan`). No + // index on the expression — the metadata cache is small (hundreds + // to low thousands of rows) and the request runs ≤ 12 lookups, so + // a single scan stays cheap. + let placeholders = canonicals.iter().map(|_| "?").collect::>().join(","); + let sql = format!( + "SELECT name, picture_url, picture_hash + FROM app.metadata_artist + WHERE LOWER(TRIM(name)) IN ({placeholders}) + AND expires_at > ?" + ); + let mut q = sqlx::query_as::<_, (String, Option, Option)>( + sqlx::AssertSqlSafe(sql), + ); + for c in canonicals { + q = q.bind(c); + } + q = q.bind(now); + if let Ok(rows) = q.fetch_all(pool).await { + for (name, picture_url, picture_hash) in rows { + map.insert(canonical_name(&name), (picture_url, picture_hash)); + } + } + + // Identify misses, then resolve them through Deezer in parallel. + // Skipping entries with an empty canonical name keeps the search + // query meaningful — Deezer returns junk for empty strings. + let misses: Vec<&RawSimilar> = raw + .iter() + .filter(|r| { + let canon = canonical_name(&r.name); + !canon.is_empty() && !map.contains_key(&canon) + }) + .collect(); + if misses.is_empty() { + return map; + } + + let client = DeezerClient::new(); + let expires = now + CACHE_TTL_MS; + let fetched: Vec<(String, Option)> = + futures::future::join_all(misses.iter().map(|r| { + let client = &client; + let name = r.name.clone(); + async move { + let canon = canonical_name(&name); + let hit = match client.search_artist(&name).await { + Ok(hits) => hits + .into_iter() + .find(|h| canonical_name(&h.name) == canon), + Err(err) => { + tracing::warn!( + ?err, + artist = %name, + "Deezer search for similar-artist enrichment failed" + ); + None + } + }; + (canon, hit) + } + })) + .await; + + for (canon, hit) in fetched { + let Some(hit) = hit else { continue }; + let picture_url = hit.picture_xl.clone().or_else(|| hit.picture_big.clone()); + let picture_hash = match picture_url.as_deref() { + Some(url) => metadata_artwork::download_and_cache(url, artwork_dir).await, + None => None, + }; + // Persist the lookup so the next request for the SAME similar + // artist (or a different page asking about them) reuses this + // result instead of poking Deezer again. ON CONFLICT also + // refreshes the expiry for entries that already existed but + // were expired — same shape as `enrich_artist_deezer`. + let _ = sqlx::query( + "INSERT INTO app.metadata_artist + (deezer_id, name, picture_url, picture_hash, fetched_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(deezer_id) DO UPDATE SET + name = excluded.name, + picture_url = excluded.picture_url, + picture_hash = excluded.picture_hash, + fetched_at = excluded.fetched_at, + expires_at = excluded.expires_at", + ) + .bind(hit.id) + .bind(&hit.name) + .bind(picture_url.as_deref()) + .bind(picture_hash.as_deref()) + .bind(now) + .bind(expires) + .execute(pool) + .await; + + map.insert(canon, (picture_url, picture_hash)); + } + + map +} + /// Populate `app.lastfm_similar` for `artist_id` when the cache row is /// missing or stale. Used by `start_radio` so that the very first /// "Démarrer la radio" click on a new artist still pulls similar From 49bc763564a29256077b71970444986dc571e28c Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 25 May 2026 14:14:03 +0200 Subject: [PATCH 2/3] fix(similar-artists): address review on enrich_with_deezer_pictures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues raised in PR #156 review, all valid against the current code: 1. **Silent failure swallowing.** `if let Ok(rows) = q.fetch_all(...)` and `let _ = sqlx::query(...).execute(...)` were dropping DB errors on the floor — direct violation of the project's no-silent-failures policy. Both now match the Err arm and log via `tracing::warn!` with the right context (cache lookup vs upsert). 2. **Concurrency cap absent + over-enrichment.** `join_all` over the raw miss list could fan out > RESULT_LIMIT requests when the Deezer fallback returned > 12 hits, and the surplus was thrown away by `.take(RESULT_LIMIT)` downstream. Switched to `futures::stream::iter(...).buffer_unordered(CONCURRENCY_LIMIT)` and sliced misses to `raw[..RESULT_LIMIT]` upfront so every fired request maps to an entry that'll actually display. 3. **Canonical-name SQL mismatch.** The cache lookup used `LOWER(TRIM(name))` while `canonicals` came from `canonical_name()` which strips non-alphanumerics. Artists like AC/DC and P!nk would never match, forcing a Deezer round-trip every request. SQLite's stock build has no REGEXP function so an equivalent SQL predicate isn't reachable without a stored column + migration. Switched to a `WHERE expires_at > ?` scan + Rust-side `canonical_name()` filter — table is bounded at ~thousands of rows so the scan is sub-ms, and the comparison is now byte-for-byte identical to the scanner's. Side effects: 1) offline mode now serves stale cache instead of returning an empty map (the cache read is local); 2) `DeezerClient` derives `Clone` so the buffered stream can stamp it into each future without HRTB lifetime grief — internal field is already Arc-backed so clone is a refcount bump. --- docs/features/integrations.md | 2 +- src-tauri/src/commands/similar.rs | 116 ++++++++++++++++++++---------- src-tauri/src/deezer.rs | 4 ++ 3 files changed, 84 insertions(+), 38 deletions(-) diff --git a/docs/features/integrations.md b/docs/features/integrations.md index 2c5c45b..78a0c4a 100644 --- a/docs/features/integrations.md +++ b/docs/features/integrations.md @@ -41,7 +41,7 @@ The frontend helper `lib/tauri/artwork.ts::resolveRemoteImage` prefers the local Results are cached in `app.lastfm_similar` (30-day TTL, keyed by the source artist's canonical name — same `canonical_name()` routine as the scanner). Each suggestion is augmented at query time with a `library_artist_id` when its canonical name matches a row in the active profile, so the UI can badge it as "in your library" and route the click back to the local artist page. Suggestions outside the library are rendered greyed out and non-interactive — no in-app destination exists for them yet. -**Picture enrichment.** Last.fm's `artist.getSimilar` returns the same generic star placeholder URL for every result (their artist-image endpoint was retired in 2019). To avoid a sea of grey stars when the cascade picks the Last.fm branch, `get_similar_artists` runs the raw list through `enrich_with_deezer_pictures` before responding: it joins against the cross-profile `app.metadata_artist` cache by canonical name, fans out parallel Deezer `search_artist` calls for cache misses (≤ 12 in flight, bounded by the result limit), and writes the new rows back so the picture survives for 30 days. The DTO's `picture_url` is rewritten to the Deezer URL whenever one is available; `picture_path` prefers the in-library hash (set by the existing profile-DB join) before falling back to the freshly cached `metadata_artist.picture_hash`. Skipped silently when offline mode is on — stale cache only. +**Picture enrichment.** Last.fm's `artist.getSimilar` returns the same generic star placeholder URL for every result (their artist-image endpoint was retired in 2019). To avoid a sea of grey stars when the cascade picks the Last.fm branch, `get_similar_artists` runs the raw list through `enrich_with_deezer_pictures` before responding: it pulls every non-expired row from the cross-profile `app.metadata_artist` cache in a single SELECT, then filters in Rust against `canonical_name(&row.name)` so artists with punctuation (e.g. AC/DC, P!nk) match correctly — SQLite's standard build has no REGEXP function so a `LOWER(TRIM(name))` predicate would mismatch the scanner's alphanumeric canonicalisation. Cache misses fan out to Deezer `search_artist` through a `futures::stream::buffer_unordered(CONCURRENCY_LIMIT)` bounded at 12, and the miss set itself is trimmed to `RESULT_LIMIT` so we never burn network on entries the caller's `.take(RESULT_LIMIT)` will drop. New rows are upserted back so the picture survives for 30 days. The DTO's `picture_url` is rewritten to the Deezer URL whenever one is available; `picture_path` prefers the in-library hash (set by the existing profile-DB join) before falling back to the freshly cached `metadata_artist.picture_hash`. When offline mode is on the function still serves whatever's in the cache (local read) but skips the network refresh. DB errors on both the cache read and the upsert are logged via `tracing::warn!` and degrade to "no enrichment" — never block the response. ### Authenticated (scrobbling) diff --git a/src-tauri/src/commands/similar.rs b/src-tauri/src/commands/similar.rs index e55954e..2384aab 100644 --- a/src-tauri/src/commands/similar.rs +++ b/src-tauri/src/commands/similar.rs @@ -162,7 +162,7 @@ pub async fn get_similar_artists( // every similar artist that isn't already in their library. // This step is a no-op when offline mode is on. let metadata_map = - enrich_with_deezer_pictures(&pool, &artwork_dir, &canonicals, &raw, now).await; + enrich_with_deezer_pictures(&pool, &artwork_dir, &raw, now).await; let out = raw .into_iter() @@ -199,6 +199,12 @@ pub async fn get_similar_artists( Ok(out) } +/// Hard cap on concurrent Deezer `search_artist` round-trips kicked off +/// by the enrichment fan-out. Matches `RESULT_LIMIT` — the displayed +/// list is capped at 12, so there's never a reason to overlap more than +/// 12 outbound requests for a single artist-page click. +const CONCURRENCY_LIMIT: usize = RESULT_LIMIT; + /// Backfill `app.metadata_artist` for every name in `raw` that we don't /// already have a non-expired cache row for, then return a /// `canonical_name → (picture_url, picture_hash)` lookup map ready to @@ -207,65 +213,92 @@ pub async fn get_similar_artists( /// The cache is the cross-profile `app.metadata_artist` so the work is /// shared with other features (the `ArtistDetailView` Deezer enrichment, /// the Wrapped year-in-review, etc.). Cache misses fan out to Deezer -/// `search_artist` in parallel — bounded by `RESULT_LIMIT` so we never -/// fire more than 12 requests per artist-page click. +/// `search_artist` through a buffered stream bounded by +/// [`CONCURRENCY_LIMIT`], and the miss set is itself trimmed to +/// [`RESULT_LIMIT`] so we never spend network on entries that the +/// final `.take(RESULT_LIMIT)` will drop anyway. async fn enrich_with_deezer_pictures( pool: &SqlitePool, artwork_dir: &std::path::Path, - canonicals: &[String], raw: &[RawSimilar], now: i64, ) -> HashMap, Option)> { + use futures::stream::StreamExt; + let mut map: HashMap, Option)> = HashMap::new(); - if canonicals.is_empty() || crate::offline::is_offline() { + if raw.is_empty() { return map; } - // Pull every relevant cached row in one round-trip. `LOWER(TRIM(name))` - // matches `canonical_name` precisely (see `commands::scan`). No - // index on the expression — the metadata cache is small (hundreds - // to low thousands of rows) and the request runs ≤ 12 lookups, so - // a single scan stays cheap. - let placeholders = canonicals.iter().map(|_| "?").collect::>().join(","); - let sql = format!( + // Pull every non-expired metadata row in one round-trip then filter + // in Rust against `canonical_name()`. We deliberately don't push the + // canonicalisation into SQL (`LOWER(TRIM(name))` would mismatch + // names like "AC/DC" → "acdc" or "P!nk" → "pnk" because SQLite's + // standard build has no REGEXP function), and SQLite has no + // user-defined alphanumeric filter. The cache is bounded by the + // user's library size + enrichment activity (~hundreds to a few + // thousand rows in steady state), so a full table scan + Rust-side + // filter is sub-millisecond and removes the canonicalisation + // mismatch bug. Promote to a stored `canonical_name` column with an + // index if profiling ever flags this query. + let canon_targets: std::collections::HashSet = raw + .iter() + .map(|r| canonical_name(&r.name)) + .filter(|c| !c.is_empty()) + .collect(); + match sqlx::query_as::<_, (String, Option, Option)>( "SELECT name, picture_url, picture_hash FROM app.metadata_artist - WHERE LOWER(TRIM(name)) IN ({placeholders}) - AND expires_at > ?" - ); - let mut q = sqlx::query_as::<_, (String, Option, Option)>( - sqlx::AssertSqlSafe(sql), - ); - for c in canonicals { - q = q.bind(c); - } - q = q.bind(now); - if let Ok(rows) = q.fetch_all(pool).await { - for (name, picture_url, picture_hash) in rows { - map.insert(canonical_name(&name), (picture_url, picture_hash)); + WHERE expires_at > ?", + ) + .bind(now) + .fetch_all(pool) + .await + { + Ok(rows) => { + for (name, picture_url, picture_hash) in rows { + let canon = canonical_name(&name); + if canon_targets.contains(&canon) { + map.insert(canon, (picture_url, picture_hash)); + } + } + } + Err(err) => { + tracing::warn!( + ?err, + "similar-artist picture cache lookup failed — falling through to Deezer" + ); } } - // Identify misses, then resolve them through Deezer in parallel. - // Skipping entries with an empty canonical name keeps the search - // query meaningful — Deezer returns junk for empty strings. - let misses: Vec<&RawSimilar> = raw + // Trim misses to the same RESULT_LIMIT window the caller's `.take` + // applies. `raw` arrives ordered by upstream affinity (Last.fm + // match score or Deezer ranking) so the first slice is exactly + // the entries that'll end up on screen. Collect owned names so the + // downstream stream owns its inputs — avoids HRTB lifetime grief + // with `buffer_unordered` borrowing back into `raw`. + let miss_names: Vec = raw[..raw.len().min(RESULT_LIMIT)] .iter() - .filter(|r| { + .filter_map(|r| { let canon = canonical_name(&r.name); - !canon.is_empty() && !map.contains_key(&canon) + if !canon.is_empty() && !map.contains_key(&canon) { + Some(r.name.clone()) + } else { + None + } }) .collect(); - if misses.is_empty() { + if miss_names.is_empty() || crate::offline::is_offline() { + // Offline mode still serves whatever's in the cache (the read + // above is local); we just skip the network refresh. return map; } let client = DeezerClient::new(); let expires = now + CACHE_TTL_MS; let fetched: Vec<(String, Option)> = - futures::future::join_all(misses.iter().map(|r| { - let client = &client; - let name = r.name.clone(); + futures::stream::iter(miss_names.into_iter().map(|name| { + let client = client.clone(); async move { let canon = canonical_name(&name); let hit = match client.search_artist(&name).await { @@ -284,6 +317,8 @@ async fn enrich_with_deezer_pictures( (canon, hit) } })) + .buffer_unordered(CONCURRENCY_LIMIT) + .collect() .await; for (canon, hit) in fetched { @@ -298,7 +333,7 @@ async fn enrich_with_deezer_pictures( // result instead of poking Deezer again. ON CONFLICT also // refreshes the expiry for entries that already existed but // were expired — same shape as `enrich_artist_deezer`. - let _ = sqlx::query( + if let Err(err) = sqlx::query( "INSERT INTO app.metadata_artist (deezer_id, name, picture_url, picture_hash, fetched_at, expires_at) VALUES (?, ?, ?, ?, ?, ?) @@ -316,7 +351,14 @@ async fn enrich_with_deezer_pictures( .bind(now) .bind(expires) .execute(pool) - .await; + .await + { + tracing::warn!( + ?err, + artist = %hit.name, + "metadata_artist upsert failed during similar-artist enrichment" + ); + } map.insert(canon, (picture_url, picture_hash)); } diff --git a/src-tauri/src/deezer.rs b/src-tauri/src/deezer.rs index a942f23..9d240c2 100644 --- a/src-tauri/src/deezer.rs +++ b/src-tauri/src/deezer.rs @@ -15,6 +15,10 @@ const USER_AGENT: &str = "WaveFlow/0.1"; const TIMEOUT_SECS: u64 = 5; /// Thin wrapper around `reqwest::Client` pre-configured for Deezer. +/// `Clone` is cheap — `reqwest::Client` is `Arc`-backed — and lets +/// callers stamp the client into each future of a `buffer_unordered` +/// stream without hitting the closure-lifetime HRTB wall. +#[derive(Clone)] pub struct DeezerClient { http: reqwest::Client, } From b70c1c4a1d2ecf5a40ceee3cb0351f737cac0db0 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 25 May 2026 14:24:02 +0200 Subject: [PATCH 3/3] fix(similar-artists): serve expired cache in offline mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cache lookup applied `WHERE expires_at > ?` unconditionally and the offline check sat downstream. Net effect: when the user was offline AND the metadata_artist row had expired, the enrichment helper returned an empty map for that artist — even though the `metadata_artwork/.jpg` blob was still on disk and a stale picture is strictly better than a grey star (we have no way to refresh the metadata until network returns anyway). Split the cache query in two: the offline branch reads `SELECT … FROM app.metadata_artist` (no TTL filter) and short-circuits before the miss computation; the online branch keeps the `WHERE expires_at > ?` predicate so expired rows correctly fall through to a Deezer refresh. The shared metadata_artwork blob cache has no TTL, so any hash we return that still has a file on disk will render — `existing_path` already guards the resolution. Hashes whose file was purged just degrade to `picture_url` (remote) which won't load offline but matches the pre-change behaviour for missing files. --- docs/features/integrations.md | 2 +- src-tauri/src/commands/similar.rs | 44 ++++++++++++++++++++++--------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/docs/features/integrations.md b/docs/features/integrations.md index 78a0c4a..69912e1 100644 --- a/docs/features/integrations.md +++ b/docs/features/integrations.md @@ -41,7 +41,7 @@ The frontend helper `lib/tauri/artwork.ts::resolveRemoteImage` prefers the local Results are cached in `app.lastfm_similar` (30-day TTL, keyed by the source artist's canonical name — same `canonical_name()` routine as the scanner). Each suggestion is augmented at query time with a `library_artist_id` when its canonical name matches a row in the active profile, so the UI can badge it as "in your library" and route the click back to the local artist page. Suggestions outside the library are rendered greyed out and non-interactive — no in-app destination exists for them yet. -**Picture enrichment.** Last.fm's `artist.getSimilar` returns the same generic star placeholder URL for every result (their artist-image endpoint was retired in 2019). To avoid a sea of grey stars when the cascade picks the Last.fm branch, `get_similar_artists` runs the raw list through `enrich_with_deezer_pictures` before responding: it pulls every non-expired row from the cross-profile `app.metadata_artist` cache in a single SELECT, then filters in Rust against `canonical_name(&row.name)` so artists with punctuation (e.g. AC/DC, P!nk) match correctly — SQLite's standard build has no REGEXP function so a `LOWER(TRIM(name))` predicate would mismatch the scanner's alphanumeric canonicalisation. Cache misses fan out to Deezer `search_artist` through a `futures::stream::buffer_unordered(CONCURRENCY_LIMIT)` bounded at 12, and the miss set itself is trimmed to `RESULT_LIMIT` so we never burn network on entries the caller's `.take(RESULT_LIMIT)` will drop. New rows are upserted back so the picture survives for 30 days. The DTO's `picture_url` is rewritten to the Deezer URL whenever one is available; `picture_path` prefers the in-library hash (set by the existing profile-DB join) before falling back to the freshly cached `metadata_artist.picture_hash`. When offline mode is on the function still serves whatever's in the cache (local read) but skips the network refresh. DB errors on both the cache read and the upsert are logged via `tracing::warn!` and degrade to "no enrichment" — never block the response. +**Picture enrichment.** Last.fm's `artist.getSimilar` returns the same generic star placeholder URL for every result (their artist-image endpoint was retired in 2019). To avoid a sea of grey stars when the cascade picks the Last.fm branch, `get_similar_artists` runs the raw list through `enrich_with_deezer_pictures` before responding: it pulls every non-expired row from the cross-profile `app.metadata_artist` cache in a single SELECT, then filters in Rust against `canonical_name(&row.name)` so artists with punctuation (e.g. AC/DC, P!nk) match correctly — SQLite's standard build has no REGEXP function so a `LOWER(TRIM(name))` predicate would mismatch the scanner's alphanumeric canonicalisation. Cache misses fan out to Deezer `search_artist` through a `futures::stream::buffer_unordered(CONCURRENCY_LIMIT)` bounded at 12, and the miss set itself is trimmed to `RESULT_LIMIT` so we never burn network on entries the caller's `.take(RESULT_LIMIT)` will drop. New rows are upserted back so the picture survives for 30 days. The DTO's `picture_url` is rewritten to the Deezer URL whenever one is available; `picture_path` prefers the in-library hash (set by the existing profile-DB join) before falling back to the freshly cached `metadata_artist.picture_hash`. When offline mode is on the function reads the cache **without** the `expires_at > now` predicate (we have no way to refresh anyway, and the `metadata_artwork/.jpg` blob never expires — serving a stale picture beats showing a grey star) and then short-circuits before the network refresh. DB errors on both the cache read and the upsert are logged via `tracing::warn!` and degrade to "no enrichment" — never block the response. ### Authenticated (scrobbling) diff --git a/src-tauri/src/commands/similar.rs b/src-tauri/src/commands/similar.rs index 2384aab..4b8d922 100644 --- a/src-tauri/src/commands/similar.rs +++ b/src-tauri/src/commands/similar.rs @@ -246,15 +246,31 @@ async fn enrich_with_deezer_pictures( .map(|r| canonical_name(&r.name)) .filter(|c| !c.is_empty()) .collect(); - match sqlx::query_as::<_, (String, Option, Option)>( - "SELECT name, picture_url, picture_hash - FROM app.metadata_artist - WHERE expires_at > ?", - ) - .bind(now) - .fetch_all(pool) - .await - { + + // Offline mode reads the cache *without* the TTL filter — we have + // no way to refresh anyway, and any locally cached picture (the + // shared `metadata_artwork/.jpg` blob is keyed by blake3 and + // never expires) is strictly better than a grey star. Online mode + // applies `expires_at > now` so an expired row falls through to a + // Deezer refresh. + let offline = crate::offline::is_offline(); + let cache_result = if offline { + sqlx::query_as::<_, (String, Option, Option)>( + "SELECT name, picture_url, picture_hash FROM app.metadata_artist", + ) + .fetch_all(pool) + .await + } else { + sqlx::query_as::<_, (String, Option, Option)>( + "SELECT name, picture_url, picture_hash + FROM app.metadata_artist + WHERE expires_at > ?", + ) + .bind(now) + .fetch_all(pool) + .await + }; + match cache_result { Ok(rows) => { for (name, picture_url, picture_hash) in rows { let canon = canonical_name(&name); @@ -271,6 +287,12 @@ async fn enrich_with_deezer_pictures( } } + // Network refresh is out of the question when offline — short-circuit + // here so neither the miss computation nor the Deezer fan-out runs. + if offline { + return map; + } + // Trim misses to the same RESULT_LIMIT window the caller's `.take` // applies. `raw` arrives ordered by upstream affinity (Last.fm // match score or Deezer ranking) so the first slice is exactly @@ -288,9 +310,7 @@ async fn enrich_with_deezer_pictures( } }) .collect(); - if miss_names.is_empty() || crate::offline::is_offline() { - // Offline mode still serves whatever's in the cache (the read - // above is local); we just skip the network refresh. + if miss_names.is_empty() { return map; }