feat(album): Last.fm similar-albums carousel under suggestions

Second album-page row sourced from Last.fm: the album's primary artist ->
artist.getSimilar -> one top album per similar artist, resolved to Qobuz and
deduped against the Qobuz /album/suggest row (by artist|title and by id).
Gated on Last.fm being connected; resolved row cached per album for 30 days
(re-opens make zero API calls; only non-empty results cached so a transient
failure re-fetches). New build_similar_albums_seeded in qbz-external-reco.
reset_album now also clears this row so the previous album's recommendations
don't linger while the next loads.

cc
This commit is contained in:
vicrodh committed 2026-06-30 13:13:50 -06:00
1 parent 8c18287577
commit 9b9bffbf64
8 files changed
+265 -5

No files matched your search

+70
View File
@@ -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<AlbumReco> {
let Some(lf) = &inputs.lastfm else {
return Vec::new();
};
if seed_artist.trim().is_empty() {
return Vec::new();
}
let exclude_keys: HashSet<String> =
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<qbz_integrations::lastfm::LastFmAlbum>)> =
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<String> = HashSet::new();
let mut candidates: Vec<AlbumCandidate> = 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<AlbumReco> {
+10
View File
@@ -114,6 +114,16 @@ pub async fn build_weekly_jams(inputs: &RecoInputs<'_>) -> Vec<TrackReco> {
pub async fn build_deep_cut_albums(inputs: &RecoInputs<'_>) -> Vec<AlbumReco> {
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<AlbumReco> {
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<AlbumReco>, Vec<ArtistReco>) {
carousels::build_editorial(inputs).await
@@ -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);
}
}
}
}
+5
View File
@@ -606,6 +606,11 @@ export global AlbumState {
// webplayer) from /album/suggest. Album cards -> open album.
in property <DiscoverSection> suggestions-section;
in property <bool> 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 <DiscoverSection> lastfm-suggestions-section;
in property <bool> 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
+34
View File
@@ -835,6 +835,38 @@ pub fn apply_suggestions(window: &AppWindow, data: Suggestions) -> Vec<ArtworkJo
jobs
}
/// Apply the Last.fm "similar albums" carousel (sits under the Qobuz
/// suggestions, same heading). Runs on the Slint event loop. Returns its
/// artwork jobs. `recos` is already deduped against the Qobuz row by the caller.
pub fn apply_lastfm_suggestions(
window: &AppWindow,
recos: Vec<qbz_external_reco::AlbumReco>,
) -> Vec<ArtworkJob> {
let items: Vec<AlbumCardItem> = 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::<AlbumState>();
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());
+13
View File
@@ -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::<crate::AlbumState>()
.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::<crate::AwardState>().get_albums();
if let Some(mut item) = model.row_data(index) {
+86 -4
View File
@@ -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<AppRuntime<SlintAdapter>>,
album_id: &str,
seed_artist: &str,
exclude_pairs: &[(String, String)],
exclude_ids: &std::collections::HashSet<String>,
) -> Vec<AlbumReco> {
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::<Vec<AlbumReco>>(&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(),
+30 -1
View File
@@ -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<String> =
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);
});
});