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:
8 files changed
+265
-5
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user