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