diff --git a/crates/qbz-external-reco/src/carousels.rs b/crates/qbz-external-reco/src/carousels.rs index 14220125e..00882b05a 100644 --- a/crates/qbz-external-reco/src/carousels.rs +++ b/crates/qbz-external-reco/src/carousels.rs @@ -315,6 +315,76 @@ pub async fn build_rec_albums(inputs: &RecoInputs<'_>, history: &ExtHistory) -> rotate_take(pool, inputs.rotation_seed, DISPLAY_CAP) } +// ── Seeded similar albums (album page: similar to THIS album) ─────────────── + +/// "Albums similar to this one" for the album page. There is no Last.fm +/// album-similarity endpoint, so we replicate it from artist similarity: +/// `seed_artist` -> artist.getSimilar -> one top (not-slop, not-excluded) album +/// per similar artist -> resolve to the Qobuz catalog. `exclude_pairs` are the +/// (artist, title) of albums already shown by the Qobuz `/album/suggest` row, +/// so the two carousels don't overlap. Empty when Last.fm is not connected. +pub async fn build_similar_albums_seeded( + inputs: &RecoInputs<'_>, + seed_artist: &str, + exclude_pairs: &[(String, String)], +) -> Vec { + let Some(lf) = &inputs.lastfm else { + return Vec::new(); + }; + if seed_artist.trim().is_empty() { + return Vec::new(); + } + let exclude_keys: HashSet = + exclude_pairs.iter().map(|(a, t)| album_key(a, t)).collect(); + let seed_key = normalize(seed_artist); + + let sims = lf + .client + .get_similar_artists(seed_artist, 30) + .await + .unwrap_or_default(); + + let per_artist: Vec<(String, Vec)> = + stream::iter(sims.into_iter().map(|s| { + let lf = lf; + async move { + let albums = lf.client.get_artist_top_albums(&s.name, 6).await.unwrap_or_default(); + (s.name, albums) + } + })) + .buffered(4) + .collect() + .await; + + let mut seen: HashSet = HashSet::new(); + let mut candidates: Vec = Vec::new(); + for (artist, albums) in per_artist { + // Skip a similar artist that IS the seed (self-similarity edge case). + if normalize(&artist) == seed_key { + continue; + } + for al in albums { + let k = album_key(&al.artist, &al.name); + if exclude_keys.contains(&k) || is_slop(&al.artist, &al.name) || !seen.insert(k) { + continue; + } + candidates.push(AlbumCandidate { + artist: al.artist.clone(), + title: al.name, + upc: None, + source: RecoSource::LastFm, + score: al.playcount as f32, + subtitle: format!("Similar to {artist}"), + }); + // One album per similar artist, so the row spans many artists. + break; + } + } + candidates.truncate(40); + let pool = validate_album_pool(inputs.catalog, inputs.cache, candidates).await; + rotate_take(pool, inputs.rotation_seed, DISPLAY_CAP) +} + // ── Fresh Releases (ListenBrainz, from artists you follow) ────────────────── pub async fn build_fresh_releases(inputs: &RecoInputs<'_>) -> Vec { diff --git a/crates/qbz-external-reco/src/lib.rs b/crates/qbz-external-reco/src/lib.rs index abe34e519..bd47ba77f 100644 --- a/crates/qbz-external-reco/src/lib.rs +++ b/crates/qbz-external-reco/src/lib.rs @@ -114,6 +114,16 @@ pub async fn build_weekly_jams(inputs: &RecoInputs<'_>) -> Vec { pub async fn build_deep_cut_albums(inputs: &RecoInputs<'_>) -> Vec { carousels::build_deep_cut_albums(inputs).await } +/// Album page: albums similar to a seed album, derived from its primary +/// artist's Last.fm similar artists (one top album each). `exclude_pairs` are +/// the (artist, title) already shown by the Qobuz suggestions row. +pub async fn build_similar_albums_seeded( + inputs: &RecoInputs<'_>, + seed_artist: &str, + exclude_pairs: &[(String, String)], +) -> Vec { + carousels::build_similar_albums_seeded(inputs, seed_artist, exclude_pairs).await +} /// Cold-start editorial (top albums + artists). pub async fn build_editorial(inputs: &RecoInputs<'_>) -> (Vec, Vec) { carousels::build_editorial(inputs).await diff --git a/crates/qbz-ui/ui/album/AlbumPageView.slint b/crates/qbz-ui/ui/album/AlbumPageView.slint index 71109bc7c..a8e4a8180 100644 --- a/crates/qbz-ui/ui/album/AlbumPageView.slint +++ b/crates/qbz-ui/ui/album/AlbumPageView.slint @@ -1089,6 +1089,23 @@ export component AlbumPageView inherits Rectangle { root.media-action(k, i, a); } } + + // Second suggestions row, from Last.fm similar artists (one album + // each), deduped against the Qobuz row above. Grouped UNDER the same + // "Suggestions" heading (the carousel carries an empty title, no + // sub-label). Hidden unless Last.fm is connected and the row + // resolved at least one album. + if AlbumState.show-lastfm-suggestions: Rectangle { height: 14px; } + if AlbumState.show-lastfm-suggestions: Carousel { + section: AlbumState.lastfm-suggestions-section; + show-view-all: false; + album-clicked(id) => { + root.media-action("album", id, "open"); + } + media-action(k, i, a) => { + root.media-action(k, i, a); + } + } } } diff --git a/crates/qbz-ui/ui/state.slint b/crates/qbz-ui/ui/state.slint index 093d76a5e..c9c69f24e 100644 --- a/crates/qbz-ui/ui/state.slint +++ b/crates/qbz-ui/ui/state.slint @@ -606,6 +606,11 @@ export global AlbumState { // webplayer) from /album/suggest. Album cards -> open album. in property suggestions-section; in property show-suggestions: false; + // Second suggestions row, sourced from Last.fm similar artists (one album + // each), deduped against the Qobuz row. Sits under the same heading, gated + // on Last.fm being connected. Empty -> hidden. + in property lastfm-suggestions-section; + in property show-lastfm-suggestions: false; // External links sidebar block (Last.fm / Discogs / MusicBrainz), // Feishin-style. URLs are prebuilt by the controller from artist + title; // each row opens its url via AlbumActions.open-external-link. Hidden for diff --git a/crates/qbz/src/album.rs b/crates/qbz/src/album.rs index 73329eb1e..5daa68e25 100644 --- a/crates/qbz/src/album.rs +++ b/crates/qbz/src/album.rs @@ -835,6 +835,38 @@ pub fn apply_suggestions(window: &AppWindow, data: Suggestions) -> Vec, +) -> Vec { + let items: Vec = recos + .iter() + .map(crate::external_reco::album_card) + .collect(); + let jobs = recos + .iter() + .enumerate() + .filter(|(_, a)| !a.artwork_url.is_empty()) + .map(|(i, a)| ArtworkJob { + url: a.artwork_url.clone(), + target: ArtworkTarget::AlbumLastfmSuggestion { index: i }, + }) + .collect(); + let show = !items.is_empty(); + let section = DiscoverSection { + title: "".into(), + endpoint: "".into(), + albums: ModelRc::new(VecModel::from(items)), + }; + let state = window.global::(); + state.set_lastfm_suggestions_section(section); + state.set_show_lastfm_suggestions(show); + jobs +} + /// Filter the visible track list by `query` (case-insensitive match on /// title or artist), against the unfiltered list kept in `FULL_TRACKS`. /// Runs on the Slint event loop. @@ -876,6 +908,8 @@ pub fn reset_album(window: &AppWindow) { state.set_show_more_from_artist(false); state.set_suggestions_section(DiscoverSection::default()); state.set_show_suggestions(false); + state.set_lastfm_suggestions_section(DiscoverSection::default()); + state.set_show_lastfm_suggestions(false); state.set_show_external_links(false); state.set_lastfm_url("".into()); state.set_discogs_url("".into()); diff --git a/crates/qbz/src/artwork.rs b/crates/qbz/src/artwork.rs index fcbd45a4d..955f2937e 100644 --- a/crates/qbz/src/artwork.rs +++ b/crates/qbz/src/artwork.rs @@ -107,6 +107,9 @@ pub enum ArtworkTarget { /// A card in AlbumState.suggestions-section.albums[index] (album-view /// "Listening suggestions" carousel). AlbumSuggestion { index: usize }, + /// A card in AlbumState.lastfm-suggestions-section.albums[index] + /// (album-view Last.fm similar-albums carousel, under the suggestions). + AlbumLastfmSuggestion { index: usize }, /// A card in AwardState.albums[index] (landing preview grid AND the /// full AwardAlbums listing — both source the `albums` model). AwardAlbum { index: usize }, @@ -1075,6 +1078,16 @@ fn apply_artwork( model.set_row_data(index, item); } } + ArtworkTarget::AlbumLastfmSuggestion { index } => { + let model = window + .global::() + .get_lastfm_suggestions_section() + .albums; + if let Some(mut item) = model.row_data(index) { + item.artwork = image; + model.set_row_data(index, item); + } + } ArtworkTarget::AwardAlbum { index } => { let model = window.global::().get_albums(); if let Some(mut item) = model.row_data(index) { diff --git a/crates/qbz/src/external_reco.rs b/crates/qbz/src/external_reco.rs index 1e57d4e49..87a41ed7c 100644 --- a/crates/qbz/src/external_reco.rs +++ b/crates/qbz/src/external_reco.rs @@ -17,9 +17,10 @@ use async_trait::async_trait; use qbz_app::shell::AppRuntime; use qbz_external_reco::{ build_deep_cut_albums, build_editorial, build_fresh_releases, build_rec_albums, - build_rec_artists_common, build_rec_artists_recent, build_weekly_exploration, build_weekly_jams, - gather_history, is_cold_start, AlbumReco, ArtistReco, ExternalCarousels, LastFmHandle, - ListenBrainzHandle, LocalHistory, RecoCache, RecoCatalog, RecoInputs, TrackReco, + build_rec_artists_common, build_rec_artists_recent, build_similar_albums_seeded, + build_weekly_exploration, build_weekly_jams, gather_history, is_cold_start, AlbumReco, + ArtistReco, ExternalCarousels, LastFmHandle, ListenBrainzHandle, LocalHistory, RecoCache, + RecoCatalog, RecoInputs, TrackReco, }; use qbz_integrations::{LastFmClient, ListenBrainzClient, MusicBrainzClient}; use qbz_models::{Album, Artist, Track}; @@ -149,6 +150,87 @@ pub fn force_reload( spawn(runtime.clone(), weak.clone(), handle, image_cache.clone(), true); } +/// 30-day TTL for the album-page Last.fm row's resolved result. Similar-artist +/// data is stable; a long window keeps Last.fm/Qobuz traffic near-zero on repeat +/// opens, while still refreshing often enough that an emerging artist's growing +/// similar set is picked up within a month. +const LASTFM_SIMILAR_TTL_SECS: i64 = 30 * 86_400; + +/// Album page: build the Last.fm "similar albums" row for `seed_artist` +/// (the open album's primary artist), excluding albums already shown by the +/// Qobuz `/album/suggest` row (`exclude_pairs`/`exclude_ids`). Returns empty +/// when Last.fm is not connected. Reuses the same catalog, resolution cache, +/// and rotation as the Discover Recommendations tab. +/// +/// The resolved result is cached per `album_id` for 30 days (the same +/// `RecoCache` results store the Discover tab uses) so re-opening an album +/// makes ZERO Last.fm/Qobuz calls. Only non-empty results are cached, so a +/// transient Last.fm failure (empty result) re-fetches on the next open instead +/// of hiding the row for the whole window. +pub async fn load_similar_albums_seeded( + runtime: &Arc>, + album_id: &str, + seed_artist: &str, + exclude_pairs: &[(String, String)], + exclude_ids: &std::collections::HashSet, +) -> Vec { + let cfg = crate::scrobbler_settings::get(); + if !cfg.lastfm_is_authed() || cfg.lastfm_username.is_empty() { + return Vec::new(); + } + let cache_dir = CACHE_DIR.lock().ok().and_then(|g| g.clone()); + let cache = match &cache_dir { + Some(dir) => RecoCache::open_at(dir).ok().map(Mutex::new), + None => None, + }; + let cache_key = format!("album_lastfm:{album_id}"); + + // Cache hit: return the resolved row without any Last.fm/Qobuz traffic. + if let Some(c) = &cache { + if let Some(json) = c + .lock() + .ok() + .and_then(|g| g.get_results(&cache_key, LASTFM_SIMILAR_TTL_SECS)) + { + if let Ok(cached) = serde_json::from_str::>(&json) { + return cached; + } + } + } + + let lastfm_client = LastFmClient::new(); + let mb_client = MusicBrainzClient::new(); + let catalog = CoreRecoCatalog { + runtime: runtime.clone(), + }; + let inputs = RecoInputs { + lastfm: Some(LastFmHandle { + username: cfg.lastfm_username.clone(), + client: &lastfm_client, + }), + listenbrainz: None, + musicbrainz: &mb_client, + catalog: &catalog, + cache: cache.as_ref(), + local: LocalHistory::default(), + rotation_seed: rotation_seed(), + }; + let mut recos = build_similar_albums_seeded(&inputs, seed_artist, exclude_pairs).await; + // Drop any that resolved to a Qobuz id already shown by the Qobuz row + // (the pre-resolution artist|title dedup can miss these). + recos.retain(|r| !exclude_ids.contains(&r.qobuz_album_id)); + + // Cache only a non-empty result (an empty one is likely transient). + if !recos.is_empty() { + if let Some(c) = &cache { + if let (Ok(g), Ok(json)) = (c.lock(), serde_json::to_string(&recos)) { + g.put_results(&cache_key, &json); + } + } + } + recos +} + fn rotation_seed() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -528,7 +610,7 @@ fn slim_from_track(t: &TrackReco) -> SlimItem { following: false, } } -fn album_card(a: &AlbumReco) -> AlbumCardItem { +pub(crate) fn album_card(a: &AlbumReco) -> AlbumCardItem { AlbumCardItem { id: a.qobuz_album_id.clone().into(), title: a.title.clone().into(), diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index 939d10be8..02c4f23d8 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -1546,8 +1546,37 @@ fn navigate_album( }); let suggestions = album::load_suggestions(&runtime, &album_id).await; + // Capture the Qobuz row's (artist, title) + ids before + // `suggestions` moves — the Last.fm row dedups against them. + let exclude_pairs: Vec<(String, String)> = suggestions + .cards + .iter() + .map(|c| (c.artist.clone(), c.title.clone())) + .collect(); + let exclude_ids: std::collections::HashSet = + suggestions.cards.iter().map(|c| c.id.clone()).collect(); + { + let image_cache_sug = image_cache.clone(); + let _ = weak.upgrade_in_event_loop(move |w| { + let jobs = album::apply_suggestions(&w, suggestions); + artwork::spawn_loads(jobs, w.as_weak(), image_cache_sug); + }); + } + + // Second suggestions row, from Last.fm similar artists + // (only when Last.fm is connected). Best-effort: empty + // result hides the row. Deduped vs the Qobuz row, and + // the resolved row is cached per album for 30 days. + let lastfm_recos = crate::external_reco::load_similar_albums_seeded( + &runtime, + &album_id, + &carousel_artist_name, + &exclude_pairs, + &exclude_ids, + ) + .await; let _ = weak.upgrade_in_event_loop(move |w| { - let jobs = album::apply_suggestions(&w, suggestions); + let jobs = album::apply_lastfm_suggestions(&w, lastfm_recos); artwork::spawn_loads(jobs, w.as_weak(), image_cache); }); });