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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<blake3>.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/<blake3>.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

Expand Down
2 changes: 2 additions & 0 deletions docs/features/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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/<hash>.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)

[`scrobbler.rs`](../../src-tauri/src/scrobbler.rs) is the worker thread that drives Last.fm scrobbles:
Expand Down
211 changes: 210 additions & 1 deletion src-tauri/src/commands/similar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &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,
Expand All @@ -177,6 +199,193 @@ 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
/// 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` 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,
raw: &[RawSimilar],
now: i64,
) -> HashMap<String, (Option<String>, Option<String>)> {
use futures::stream::StreamExt;

let mut map: HashMap<String, (Option<String>, Option<String>)> = HashMap::new();
if raw.is_empty() {
return map;
}

// 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<String> = raw
.iter()
.map(|r| canonical_name(&r.name))
.filter(|c| !c.is_empty())
.collect();

// 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/<hash>.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<String>, Option<String>)>(
"SELECT name, picture_url, picture_hash FROM app.metadata_artist",
)
.fetch_all(pool)
.await
} else {
sqlx::query_as::<_, (String, Option<String>, Option<String>)>(
"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);
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"
);
}
}

// 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
// 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<String> = raw[..raw.len().min(RESULT_LIMIT)]
.iter()
.filter_map(|r| {
let canon = canonical_name(&r.name);
if !canon.is_empty() && !map.contains_key(&canon) {
Some(r.name.clone())
} else {
None
}
})
.collect();
if miss_names.is_empty() {
return map;
}

let client = DeezerClient::new();
let expires = now + CACHE_TTL_MS;
let fetched: Vec<(String, Option<crate::deezer::DeezerArtistHit>)> =
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 {
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)
}
}))
.buffer_unordered(CONCURRENCY_LIMIT)
.collect()
.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`.
if let Err(err) = 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
{
tracing::warn!(
?err,
artist = %hit.name,
"metadata_artist upsert failed during similar-artist enrichment"
);
}

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
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/deezer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
Loading