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:
16 files changed
+1452
-653
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
Reference in new issue
Block a user