feat(reco): redesign Recommendations tab — artist/album-centric + LB playlists

Reworks the 4th Discover tab per owner feedback (the track-heavy v1 surfaced
already-heard artists). Verified against three sources that Last.fm has NO
recommendations API (getRecommendedArtists removed; web is PJAX HTML behind the
website cookie, which the API session key cannot reach) — so recommendations are
replicated from the documented similarity recipe, and ListenBrainz provides the
genuinely clean personalized feeds.

New lineup (each row paints progressively as its builder resolves):
- Recommended Artists (Last.fm artist.getSimilar seeded by last-month top, minus
  the heard set; "Similar to X, Y" subtitle on the artist card).
- Recommended Albums (Last.fm artist.getTopAlbums minus your scrobbled albums).
- Fresh Releases (ListenBrainz, from artists you follow).
- Weekly Exploration + Weekly Jams (ListenBrainz curated playlists, by source_patch).
- Deep-cut albums from artists you know (Qobuz catalog, not heard in-app).
- Cold-start fallback: Qobuz editorial top albums + artists.

The "not heard" filter is now ARTIST-level (the v1 bug derived artists from
unheard TRACKS) and compares against the LOCAL reco history + a light Last.fm/LB
top-set, not a full history sweep. Album validation (UPC -> fuzzy text) added.
qbz-integrations gains Last.fm get_artist_top_albums/get_user_top_albums and
ListenBrainz get_created_for_playlists/get_playlist_tracks/get_fresh_releases.

NOTE: the Cloudflare proxy was switched to allow ALL Last.fm read methods
(anything with ".get"), so future reads need NO proxy redeploy — but the live
proxy must be redeployed ONCE with this change or all Last.fm reads keep 400ing.

cargo check -p qbz green.

cc
This commit is contained in:
vicrodh committed 2026-06-27 08:34:24 -06:00
1 parent ec98245364
commit 0347e333a8
16 files changed
+1452 -653

No files matched your search

+165 -2
View File
@@ -4,8 +4,8 @@ use reqwest::Client;
use serde_json::json;
use super::models::{
AuthGetSessionResponse, AuthGetTokenResponse, LastFmArtist, LastFmResponse, LastFmSession,
LastFmSimilarArtist, LastFmSimilarTrack, LastFmTrack,
AuthGetSessionResponse, AuthGetTokenResponse, LastFmAlbum, LastFmArtist, LastFmResponse,
LastFmSession, LastFmSimilarArtist, LastFmSimilarTrack, LastFmTrack,
};
use crate::error::{IntegrationError, IntegrationResult};
@@ -713,6 +713,169 @@ impl LastFmClient {
Ok(tracks)
}
/// artist.getTopAlbums — an artist's most-popular albums (global playcount).
///
/// Source for "Recommended Albums" candidates. Public read endpoint — no
/// session key needed (the proxy injects the API key).
pub async fn get_artist_top_albums(
&self,
artist: &str,
limit: u32,
) -> IntegrationResult<Vec<LastFmAlbum>> {
let url = format!("{}/artist.getTopAlbums", LASTFM_PROXY_URL);
let response = self
.client
.post(&url)
.json(&json!({
"artist": artist,
"limit": limit,
}))
.send()
.await?;
if !response.status().is_success() {
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"Last.fm artist.getTopAlbums failed: {}",
text
)));
}
let text = response.text().await?;
let data: serde_json::Value = serde_json::from_str(&text)?;
if let Some(error) = data.get("error") {
let message = data
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
return Err(IntegrationError::api(
error.as_u64().unwrap_or(0) as u32,
message.to_string(),
));
}
let albums = data
.get("topalbums")
.and_then(|ta| ta.get("album"))
.and_then(|a| a.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
let name = item.get("name")?.as_str()?.to_string();
let mbid = extract_mbid(item);
let artist_obj = item.get("artist");
let artist = artist_obj
.and_then(|a| a.get("name"))
.and_then(|n| n.as_str())
.unwrap_or_default()
.to_string();
let artist_mbid = artist_obj.and_then(|a| extract_mbid(a));
let image = extract_image(item);
let playcount = parse_u64(item.get("playcount"));
Some(LastFmAlbum {
name,
artist,
artist_mbid,
mbid,
image,
playcount,
})
})
.collect()
})
.unwrap_or_default();
Ok(albums)
}
/// user.getTopAlbums — the USER's scrobbled albums (their playcount).
///
/// The "already heard" exclusion set for Recommended Albums. Public read
/// endpoint — no session key needed (the proxy injects the API key).
/// `period` must be one of: `overall|7day|1month|3month|6month|12month`.
pub async fn get_user_top_albums(
&self,
user: &str,
period: &str,
limit: u32,
page: u32,
) -> IntegrationResult<Vec<LastFmAlbum>> {
let url = format!("{}/user.getTopAlbums", LASTFM_PROXY_URL);
let response = self
.client
.post(&url)
.json(&json!({
"user": user,
"period": period,
"limit": limit,
"page": page,
}))
.send()
.await?;
if !response.status().is_success() {
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"Last.fm user.getTopAlbums failed: {}",
text
)));
}
let text = response.text().await?;
let data: serde_json::Value = serde_json::from_str(&text)?;
if let Some(error) = data.get("error") {
let message = data
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
return Err(IntegrationError::api(
error.as_u64().unwrap_or(0) as u32,
message.to_string(),
));
}
let albums = data
.get("topalbums")
.and_then(|ta| ta.get("album"))
.and_then(|a| a.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
let name = item.get("name")?.as_str()?.to_string();
let mbid = extract_mbid(item);
let artist_obj = item.get("artist");
let artist = artist_obj
.and_then(|a| a.get("name"))
.and_then(|n| n.as_str())
.unwrap_or_default()
.to_string();
let artist_mbid = artist_obj.and_then(|a| extract_mbid(a));
let image = extract_image(item);
let playcount = parse_u64(item.get("playcount"));
Some(LastFmAlbum {
name,
artist,
artist_mbid,
mbid,
image,
playcount,
})
})
.collect()
})
.unwrap_or_default();
Ok(albums)
}
}
/// Extract the largest image URL (last array entry's `#text`) from a Last.fm object.
+1 -1
View File
@@ -32,5 +32,5 @@ mod models;
pub use client::LastFmClient;
pub use models::{
LastFmArtist, LastFmSession, LastFmSimilarArtist, LastFmSimilarTrack, LastFmTrack,
LastFmAlbum, LastFmArtist, LastFmSession, LastFmSimilarArtist, LastFmSimilarTrack, LastFmTrack,
};
@@ -74,6 +74,23 @@ pub struct LastFmArtist {
pub image: Option<String>,
}
/// An album from Last.fm's artist.getTopAlbums / user.getTopAlbums
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LastFmAlbum {
/// Album name
pub name: String,
/// Artist name
pub artist: String,
/// Artist MusicBrainz ID if available
pub artist_mbid: Option<String>,
/// Album MusicBrainz ID if available
pub mbid: Option<String>,
/// Largest available image URL, if any
pub image: Option<String>,
/// Playcount (global for artist.getTopAlbums, the user's for user.getTopAlbums)
pub playcount: u64,
}
/// A track from Last.fm user methods (top / loved / recent tracks)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LastFmTrack {
@@ -602,4 +602,365 @@ impl ListenBrainzClient {
Ok(recordings)
}
/// List the "Created for you" curated playlists (Weekly Jams = familiar,
/// Weekly Exploration = discovery, Top Discoveries, etc.).
///
/// `GET /user/{user_name}/playlists/createdfor?count={count}`
///
/// Public read; the `Authorization` header is sent only when a token is
/// configured. HTTP 204/404 and empty/malformed bodies are treated as
/// "no data" -> `Ok(vec![])`.
///
/// Response shape: `{ playlists: [ { playlist: {...} }, ... ] }`. For each
/// inner `playlist` object:
/// - `title`
/// - `date` -> `created_at`
/// - `annotation`
/// - `playlist_mbid` = LAST path segment of `identifier` (string OR array;
/// e.g. `"https://listenbrainz.org/playlist/{mbid}"`)
/// - `source_patch` = `extension["https://musicbrainz.org/doc/jspf#playlist"]`
/// `.additional_metadata.algorithm_metadata.source_patch`
pub async fn get_created_for_playlists(
&self,
user_name: &str,
count: u32,
) -> IntegrationResult<Vec<LbPlaylistMeta>> {
let token = self.config.lock().await.token.clone();
let url = format!(
"{}/user/{}/playlists/createdfor",
LISTENBRAINZ_API_URL, user_name
);
let mut request = self
.client
.get(&url)
.query(&[("count", count.to_string())]);
if let Some(token) = token {
request = request.header("Authorization", format!("Token {}", token));
}
let response = request.send().await?;
let status = response.status();
if status == reqwest::StatusCode::NO_CONTENT || status == reqwest::StatusCode::NOT_FOUND {
return Ok(vec![]);
}
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"ListenBrainz created-for playlists failed: {} - {}",
status, text
)));
}
let body = response.text().await.unwrap_or_default();
if body.trim().is_empty() {
return Ok(vec![]);
}
let json: serde_json::Value = match serde_json::from_str(&body) {
Ok(value) => value,
Err(_) => return Ok(vec![]),
};
let playlists = json
.get("playlists")
.and_then(|playlists| playlists.as_array())
.cloned()
.unwrap_or_default();
let mut parsed = Vec::with_capacity(playlists.len());
for wrapper in playlists {
// Each array entry wraps the real object under a `playlist` key.
let playlist = match wrapper.get("playlist") {
Some(playlist) => playlist,
None => continue,
};
let playlist_mbid = match playlist
.get("identifier")
.and_then(last_identifier_segment)
{
Some(mbid) => mbid,
// No usable playlist id -> useless downstream; skip it.
None => continue,
};
let title = playlist
.get("title")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let created_at = playlist
.get("date")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let annotation = playlist
.get("annotation")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let source_patch = playlist
.get("extension")
.and_then(|ext| ext.get("https://musicbrainz.org/doc/jspf#playlist"))
.and_then(|ext| ext.get("additional_metadata"))
.and_then(|meta| meta.get("algorithm_metadata"))
.and_then(|algo| algo.get("source_patch"))
.and_then(|value| value.as_str())
.map(|value| value.to_string());
parsed.push(LbPlaylistMeta {
playlist_mbid,
title,
source_patch,
annotation,
created_at,
});
}
Ok(parsed)
}
/// Fetch one playlist's tracks (JSPF).
///
/// `GET /playlist/{playlist_mbid}`
///
/// Public read; the `Authorization` header is sent only when a token is
/// configured. HTTP 204/404 and empty/malformed bodies are treated as
/// "no data" -> `Ok(vec![])`.
///
/// Response shape: `{ playlist: { track: [ ... ] } }`. For each track:
/// - `title`
/// - `creator` -> `artist_name`
/// - `album` -> `release_name`
/// - `recording_mbid` = LAST path segment of the track `identifier`
/// (string OR array)
/// - `caa_id` + `caa_release_mbid` from
/// `extension["https://musicbrainz.org/doc/jspf#track"].additional_metadata`
pub async fn get_playlist_tracks(
&self,
playlist_mbid: &str,
) -> IntegrationResult<Vec<LbPlaylistTrack>> {
let token = self.config.lock().await.token.clone();
let url = format!("{}/playlist/{}", LISTENBRAINZ_API_URL, playlist_mbid);
let mut request = self.client.get(&url);
if let Some(token) = token {
request = request.header("Authorization", format!("Token {}", token));
}
let response = request.send().await?;
let status = response.status();
if status == reqwest::StatusCode::NO_CONTENT || status == reqwest::StatusCode::NOT_FOUND {
return Ok(vec![]);
}
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"ListenBrainz playlist tracks failed: {} - {}",
status, text
)));
}
let body = response.text().await.unwrap_or_default();
if body.trim().is_empty() {
return Ok(vec![]);
}
let json: serde_json::Value = match serde_json::from_str(&body) {
Ok(value) => value,
Err(_) => return Ok(vec![]),
};
let tracks = json
.get("playlist")
.and_then(|playlist| playlist.get("track"))
.and_then(|track| track.as_array())
.cloned()
.unwrap_or_default();
let mut parsed = Vec::with_capacity(tracks.len());
for track in tracks {
let title = track
.get("title")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let artist_name = track
.get("creator")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let release_name = track
.get("album")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let recording_mbid = track.get("identifier").and_then(last_identifier_segment);
let additional = track
.get("extension")
.and_then(|ext| ext.get("https://musicbrainz.org/doc/jspf#track"))
.and_then(|ext| ext.get("additional_metadata"));
let caa_id = additional
.and_then(|meta| meta.get("caa_id"))
.and_then(|value| value.as_i64());
let caa_release_mbid = additional
.and_then(|meta| meta.get("caa_release_mbid"))
.and_then(|value| value.as_str())
.map(|value| value.to_string());
parsed.push(LbPlaylistTrack {
recording_mbid,
title,
artist_name,
release_name,
caa_id,
caa_release_mbid,
});
}
Ok(parsed)
}
/// Personalized fresh releases.
///
/// `GET /user/{user_name}/fresh_releases?days={days}`
///
/// `days` is clamped to `1..=90`. Public read; the `Authorization` header
/// is sent only when a token is configured. HTTP 204/404 and empty/malformed
/// bodies are treated as "no data" -> `Ok(vec![])`.
///
/// Response shape: `{ payload: { releases: [ ... ] } }`. For each release:
/// - `release_name`
/// - `artist_credit_name`
/// - `release_mbid`
/// - `release_group_mbid`
/// - `release_group_primary_type` -> `primary_type`
/// - `caa_id`
/// - `caa_release_mbid`
/// - `release_date`
/// - `listen_count`
pub async fn get_fresh_releases(
&self,
user_name: &str,
days: u32,
) -> IntegrationResult<Vec<LbFreshRelease>> {
let token = self.config.lock().await.token.clone();
let days = days.clamp(1, 90);
let url = format!("{}/user/{}/fresh_releases", LISTENBRAINZ_API_URL, user_name);
let mut request = self.client.get(&url).query(&[("days", days.to_string())]);
if let Some(token) = token {
request = request.header("Authorization", format!("Token {}", token));
}
let response = request.send().await?;
let status = response.status();
if status == reqwest::StatusCode::NO_CONTENT || status == reqwest::StatusCode::NOT_FOUND {
return Ok(vec![]);
}
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"ListenBrainz fresh releases failed: {} - {}",
status, text
)));
}
let body = response.text().await.unwrap_or_default();
if body.trim().is_empty() {
return Ok(vec![]);
}
let json: serde_json::Value = match serde_json::from_str(&body) {
Ok(value) => value,
Err(_) => return Ok(vec![]),
};
let releases = json
.get("payload")
.and_then(|payload| payload.get("releases"))
.and_then(|releases| releases.as_array())
.cloned()
.unwrap_or_default();
let mut parsed = Vec::with_capacity(releases.len());
for release in releases {
let release_name = release
.get("release_name")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let artist_credit_name = release
.get("artist_credit_name")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let release_mbid = release
.get("release_mbid")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let release_group_mbid = release
.get("release_group_mbid")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let primary_type = release
.get("release_group_primary_type")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let caa_id = release.get("caa_id").and_then(|value| value.as_i64());
let caa_release_mbid = release
.get("caa_release_mbid")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let release_date = release
.get("release_date")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let listen_count = release.get("listen_count").and_then(|value| value.as_u64());
parsed.push(LbFreshRelease {
release_name,
artist_credit_name,
release_mbid,
release_group_mbid,
primary_type,
caa_id,
caa_release_mbid,
release_date,
listen_count,
});
}
Ok(parsed)
}
}
/// Extract the last `/`-delimited segment of a JSPF `identifier` value.
///
/// ListenBrainz returns the `identifier` either as a single string
/// (`"https://listenbrainz.org/playlist/{mbid}"`) or as an array of such
/// strings. Returns the last non-empty path segment of the first usable value,
/// or `None` when nothing parseable is present.
fn last_identifier_segment(identifier: &serde_json::Value) -> Option<String> {
fn last_segment(raw: &str) -> Option<String> {
raw.trim_end_matches('/')
.rsplit('/')
.next()
.map(str::trim)
.filter(|segment| !segment.is_empty())
.map(|segment| segment.to_string())
}
match identifier {
serde_json::Value::String(raw) => last_segment(raw),
serde_json::Value::Array(items) => items
.iter()
.filter_map(serde_json::Value::as_str)
.find_map(last_segment),
_ => None,
}
}
@@ -45,6 +45,7 @@ pub mod cache;
pub use client::{ListenBrainzClient, ListenBrainzConfig};
pub use models::{
AdditionalInfo, CfRecommendation, LbListen, LbRecordingMeta, Listen, ListenBrainzStatus,
ListenType, QueuedListen, SubmitListensPayload, TrackMetadata, UserInfo,
AdditionalInfo, CfRecommendation, LbFreshRelease, LbListen, LbPlaylistMeta, LbPlaylistTrack,
LbRecordingMeta, Listen, ListenBrainzStatus, ListenType, QueuedListen, SubmitListensPayload,
TrackMetadata, UserInfo,
};
@@ -203,3 +203,66 @@ pub struct LbRecordingMeta {
/// Cover Art Archive release MBID, if available
pub caa_release_mbid: Option<String>,
}
/// A "Created for you" curated playlist's metadata
///
/// Returned by `GET /user/{user_name}/playlists/createdfor`. Describes one of
/// the auto-generated playlists (Weekly Jams, Weekly Exploration, Top
/// Discoveries, etc.) without its tracks.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LbPlaylistMeta {
/// MusicBrainz playlist ID (last path segment of the JSPF `identifier`)
pub playlist_mbid: String,
/// Playlist title
pub title: String,
/// Generation algorithm patch name (e.g. `weekly-jams`, `weekly-exploration`)
pub source_patch: Option<String>,
/// Playlist annotation / description, if available
pub annotation: Option<String>,
/// ISO-8601 creation timestamp, if available
pub created_at: Option<String>,
}
/// A single track inside a curated playlist (JSPF entry)
///
/// Returned by `GET /playlist/{playlist_mbid}`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LbPlaylistTrack {
/// MusicBrainz recording ID (last path segment of the JSPF `identifier`)
pub recording_mbid: Option<String>,
/// Track title
pub title: String,
/// Artist name (JSPF `creator`)
pub artist_name: String,
/// Release/album name (JSPF `album`), if available
pub release_name: Option<String>,
/// Cover Art Archive image ID, if available
pub caa_id: Option<i64>,
/// Cover Art Archive release MBID, if available
pub caa_release_mbid: Option<String>,
}
/// A personalized fresh release entry
///
/// Returned by `GET /user/{user_name}/fresh_releases`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LbFreshRelease {
/// Release/album name
pub release_name: String,
/// Artist credit name
pub artist_credit_name: String,
/// MusicBrainz release ID, if available
pub release_mbid: Option<String>,
/// MusicBrainz release-group ID, if available
pub release_group_mbid: Option<String>,
/// Release-group primary type (Album, Single, EP, ...), if available
pub primary_type: Option<String>,
/// Cover Art Archive image ID, if available
pub caa_id: Option<i64>,
/// Cover Art Archive release MBID, if available
pub caa_release_mbid: Option<String>,
/// Release date (ISO-8601), if available
pub release_date: Option<String>,
/// Number of listens recorded for this release, if available
pub listen_count: Option<u64>,
}