forked from Keelrleek/SoulStream
metadata: per-track listeners, and an artist profile the search hero can ask for
CatalogTrack gains artist_id — Deezer sends it on every search row and it was simply unparsed — and rank, its 0-1,000,000 popularity figure. Together they let a listener count be resolved for the PERFORMING artist of each row rather than for the query's top artist, so a "panda bear" search shows Daft Punk's "Doin' it Right (feat. Panda Bear)" at its real 464,046. Resolution rides the existing deezer_linked_mbid -> lastfm_listener_ranks chain behind a new catalog_track_listeners command, deliberately kept separate from catalog_search: that one is typed against per keystroke and must never wait on MusicBrainz's 1 req/s gate. catalog_artist_profile backs the hero's middle third — MusicBrainz tags off the cached artist document plus one Last.fm artist.getinfo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f8f56f4398
commit
dc918c98ef
3 files changed
+285
No files matched your search
@@ -485,6 +485,20 @@ pub struct CatalogTrack {
|
|||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub artist: String,
|
pub artist: String,
|
||||||
|
/// The performing artist's Deezer id, when the provider gave one alongside
|
||||||
|
/// the name. Search results span many artists, and a NAME is not an
|
||||||
|
/// identity here — see the namesake rules on `lastfm_listener_ranks`. This
|
||||||
|
/// is what lets the search page ask for a row's listener count without ever
|
||||||
|
/// querying a provider by artist name.
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist_id: Option<u64>,
|
||||||
|
/// Deezer's own popularity score, 0–1,000,000. Present on search results,
|
||||||
|
/// and the only popularity signal available at FIRST PAINT — Last.fm
|
||||||
|
/// listeners arrive a beat later. The search page ranks its top-result
|
||||||
|
/// candidates on this, so a famous song is not outranked by an obscure
|
||||||
|
/// one-track single that happens to share its name.
|
||||||
|
#[serde(default)]
|
||||||
|
pub rank: Option<u32>,
|
||||||
pub album: String,
|
pub album: String,
|
||||||
pub album_id: Option<u64>,
|
pub album_id: Option<u64>,
|
||||||
pub cover: Option<String>,
|
pub cover: Option<String>,
|
||||||
@@ -647,7 +661,19 @@ struct ArtistSupplements {
|
|||||||
musicbrainz_unavailable: bool,
|
musicbrainz_unavailable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the search hero shows about an artist beyond Deezer's own fields.
|
||||||
|
/// Every field is independently optional — see [`MetadataClient::artist_profile`].
|
||||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||||
|
pub struct CatalogArtistProfile {
|
||||||
|
/// All-time distinct Last.fm listeners for the artist.
|
||||||
|
pub listeners: Option<u64>,
|
||||||
|
/// All-time Last.fm scrobbles for the artist.
|
||||||
|
pub plays: Option<u64>,
|
||||||
|
/// MusicBrainz's most-voted genre tags, strongest first.
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct CatalogSearch {
|
pub struct CatalogSearch {
|
||||||
pub artists: Vec<CatalogArtist>,
|
pub artists: Vec<CatalogArtist>,
|
||||||
pub albums: Vec<CatalogAlbum>,
|
pub albums: Vec<CatalogAlbum>,
|
||||||
@@ -3398,6 +3424,121 @@ impl MetadataClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The two things the search hero can say about an artist that Deezer
|
||||||
|
/// cannot: what they sound like, and how big they are.
|
||||||
|
///
|
||||||
|
/// Both hang off the linked MBID the rest of the identity chain already
|
||||||
|
/// resolves, so neither is a name guess. The genres are MusicBrainz's own
|
||||||
|
/// most-voted tags, read from the SAME cached artist document the socials
|
||||||
|
/// and bio tiers use — no extra request. The totals are one Last.fm
|
||||||
|
/// `artist.getinfo` keyed by that MBID.
|
||||||
|
///
|
||||||
|
/// Everything is optional and independently so: an artist MusicBrainz has
|
||||||
|
/// no Deezer link for returns empty rather than wrong.
|
||||||
|
pub async fn artist_profile(&self, artist_id: u64) -> CatalogArtistProfile {
|
||||||
|
let mut profile = CatalogArtistProfile::default();
|
||||||
|
let Some(mbid) = self.deezer_linked_mbid(artist_id).await else {
|
||||||
|
return profile;
|
||||||
|
};
|
||||||
|
if let Some(facts) = self.artist_identity_facts(&mbid).await {
|
||||||
|
profile.tags = facts.tags;
|
||||||
|
}
|
||||||
|
let url = format!(
|
||||||
|
"https://ws.audioscrobbler.com/2.0/?method=artist.getinfo\
|
||||||
|
&mbid={mbid}&autocorrect=1&api_key={LASTFM_KEY}&format=json"
|
||||||
|
);
|
||||||
|
if let Some(body) = self.lastfm_json(&url).await {
|
||||||
|
// Last.fm sends these as strings, like every other count it serves.
|
||||||
|
let stat = |key: &str| {
|
||||||
|
body.pointer(&format!("/artist/stats/{key}"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
|
.filter(|v| *v > 0)
|
||||||
|
};
|
||||||
|
profile.listeners = stat("listeners");
|
||||||
|
profile.plays = stat("playcount");
|
||||||
|
}
|
||||||
|
profile
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last.fm listener counts for the songs a SEARCH returned.
|
||||||
|
///
|
||||||
|
/// `catalog_search` itself is three Deezer calls and nothing else — it is
|
||||||
|
/// typed against per keystroke by the suggestion dropdown, so it may never
|
||||||
|
/// wait on MusicBrainz's 1 req/s gate. This is the deferred half, fired once
|
||||||
|
/// the results are already on screen, exactly like `catalog_artist_socials`.
|
||||||
|
///
|
||||||
|
/// Identity, not names. Each row carries the performing artist's DEEZER ID
|
||||||
|
/// (`CatalogTrack::artist_id`), which resolves through the same
|
||||||
|
/// `deezer_linked_mbid` → `lastfm_listener_ranks` chain the artist page
|
||||||
|
/// uses. Asking Last.fm for "Daft Punk" by name would re-open the namesake
|
||||||
|
/// hole that chain exists to close.
|
||||||
|
///
|
||||||
|
/// A search spans many artists, so the ranks are fetched once per DISTINCT
|
||||||
|
/// artist and every row of theirs is answered from that one lookup. The
|
||||||
|
/// title key strips featured-artist clauses, which is what lets Daft Punk's
|
||||||
|
/// "Doin' it Right (feat. Panda Bear)" match the scrobbled "Doin' it Right".
|
||||||
|
///
|
||||||
|
/// Returns one entry per input row, in order. `None` means "no count" — a
|
||||||
|
/// track Last.fm has never seen, an artist MusicBrainz has no Deezer link
|
||||||
|
/// for, or a row past the artist budget below. Never a fabricated zero.
|
||||||
|
pub async fn track_listeners(&self, tracks: &[(Option<u64>, String)]) -> Vec<Option<u64>> {
|
||||||
|
// Distinct artists, in first-seen order. Capped: a pathological result
|
||||||
|
// set spanning ten artists would otherwise spend ten MusicBrainz slots
|
||||||
|
// (one second each, shared with every other page) on the last few rows
|
||||||
|
// of one list. Six covers every real search measured.
|
||||||
|
const ARTIST_BUDGET: usize = 6;
|
||||||
|
let mut wanted: Vec<u64> = Vec::new();
|
||||||
|
let mut dropped = 0_usize;
|
||||||
|
for (artist_id, _) in tracks {
|
||||||
|
let Some(id) = artist_id else { continue };
|
||||||
|
if wanted.contains(id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if wanted.len() < ARTIST_BUDGET {
|
||||||
|
wanted.push(*id);
|
||||||
|
} else {
|
||||||
|
dropped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Said out loud rather than silently truncated: a bounded answer that
|
||||||
|
// reads as a complete one is how "these rows just have no count" hides
|
||||||
|
// a budget.
|
||||||
|
if dropped > 0 {
|
||||||
|
log::debug!(
|
||||||
|
"track_listeners: {dropped} artist(s) past the {ARTIST_BUDGET}-artist budget \
|
||||||
|
get no counts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if wanted.is_empty() {
|
||||||
|
return vec![None; tracks.len()];
|
||||||
|
}
|
||||||
|
// Concurrent by artist. Both hops are individually rate-limited and
|
||||||
|
// cached upstream, so this is a fan-out over the gates rather than
|
||||||
|
// around them.
|
||||||
|
let ranks: Vec<(u64, Option<LastfmRanks>)> =
|
||||||
|
futures::future::join_all(wanted.into_iter().map(|artist_id| async move {
|
||||||
|
let ranks = match self.deezer_linked_mbid(artist_id).await {
|
||||||
|
Some(mbid) => self.lastfm_listener_ranks(&mbid).await,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
(artist_id, ranks)
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
let by_artist: HashMap<u64, LastfmRanks> = ranks
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(id, ranks)| ranks.map(|r| (id, r)))
|
||||||
|
.collect();
|
||||||
|
tracks
|
||||||
|
.iter()
|
||||||
|
.map(|(artist_id, title)| {
|
||||||
|
let ranks = by_artist.get(&(*artist_id)?)?;
|
||||||
|
let stats = ranks.by_key.get(&track_title_identity(title))?;
|
||||||
|
(stats.listeners > 0).then_some(stats.listeners)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Artist profile plus their full discography. The artist-albums
|
/// Artist profile plus their full discography. The artist-albums
|
||||||
/// endpoint only returns Deezer's curated "main" discography (e.g. 30
|
/// endpoint only returns Deezer's curated "main" discography (e.g. 30
|
||||||
/// entries for an artist with 60+ releases), so it's merged with a paged
|
/// entries for an artist with 60+ releases), so it's merged with a paged
|
||||||
@@ -5572,6 +5713,8 @@ impl MetadataClient {
|
|||||||
id: synthetic_catalog_id(&format!("track:{source_id}")),
|
id: synthetic_catalog_id(&format!("track:{source_id}")),
|
||||||
title,
|
title,
|
||||||
artist: source.artist.clone(),
|
artist: source.artist.clone(),
|
||||||
|
artist_id: None,
|
||||||
|
rank: None,
|
||||||
album: source.title.clone(),
|
album: source.title.clone(),
|
||||||
album_id: Some(album_id),
|
album_id: Some(album_id),
|
||||||
cover: source.cover.clone(),
|
cover: source.cover.clone(),
|
||||||
@@ -5743,6 +5886,8 @@ impl MetadataClient {
|
|||||||
id: synthetic_catalog_id(&format!("apple-track:{track_id}")),
|
id: synthetic_catalog_id(&format!("apple-track:{track_id}")),
|
||||||
title,
|
title,
|
||||||
artist: source.artist.clone(),
|
artist: source.artist.clone(),
|
||||||
|
artist_id: None,
|
||||||
|
rank: None,
|
||||||
album: source.title.clone(),
|
album: source.title.clone(),
|
||||||
album_id: Some(album_id),
|
album_id: Some(album_id),
|
||||||
cover: source.cover.clone(),
|
cover: source.cover.clone(),
|
||||||
@@ -7790,6 +7935,8 @@ fn parse_track(v: &serde_json::Value) -> Option<CatalogTrack> {
|
|||||||
.and_then(|a| a.as_str())
|
.and_then(|a| a.as_str())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
artist_id: v.pointer("/artist/id").and_then(|a| a.as_u64()),
|
||||||
|
rank: v.get("rank").and_then(|r| r.as_u64()).map(|r| r as u32),
|
||||||
album: v
|
album: v
|
||||||
.pointer("/album/title")
|
.pointer("/album/title")
|
||||||
.and_then(|a| a.as_str())
|
.and_then(|a| a.as_str())
|
||||||
@@ -8555,6 +8702,8 @@ fn build_popular(
|
|||||||
// scrobbled string, which carries whatever users typed.
|
// scrobbled string, which carries whatever users typed.
|
||||||
title: recording.title.clone(),
|
title: recording.title.clone(),
|
||||||
artist: artist.to_string(),
|
artist: artist.to_string(),
|
||||||
|
artist_id: None,
|
||||||
|
rank: None,
|
||||||
album: recording.album_title.clone(),
|
album: recording.album_title.clone(),
|
||||||
// The SAME synthetic id the album supplement mints for this release
|
// The SAME synthetic id the album supplement mints for this release
|
||||||
// group, so the row points at the card the discography already
|
// group, so the row points at the card the discography already
|
||||||
@@ -12504,6 +12653,82 @@ fn urlencode(s: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
|
/// The search page's listener bars, end to end against the live providers.
|
||||||
|
///
|
||||||
|
/// Ignored by default — it spends real Deezer, MusicBrainz and Last.fm
|
||||||
|
/// requests. Run it deliberately:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// cargo test -p metadata track_listeners_live -- --ignored --nocapture
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// What it proves, and why each half matters:
|
||||||
|
///
|
||||||
|
/// 1. `catalog_search` now carries a per-row `artist_id`. Without it the
|
||||||
|
/// counts can only be resolved for one artist, and every row credited to
|
||||||
|
/// somebody else shows a dash.
|
||||||
|
/// 2. A row by a DIFFERENT artist than the one searched for still gets its
|
||||||
|
/// count. Searching "panda bear" returns Daft Punk's "Doin' it Right
|
||||||
|
/// (feat. Panda Bear)", which the album page shows ~464k listeners for;
|
||||||
|
/// it must not come back empty here.
|
||||||
|
///
|
||||||
|
/// Deliberately loose on the numbers: listener counts move every day, and a
|
||||||
|
/// test that pins them would fail for the wrong reason. It asserts the
|
||||||
|
/// SHAPE — ids present, counts resolved, cross-artist rows answered — and
|
||||||
|
/// prints the table so a human can sanity-check the figures.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "hits Deezer, MusicBrainz and Last.fm"]
|
||||||
|
async fn track_listeners_live() {
|
||||||
|
let client = super::MetadataClient::new();
|
||||||
|
let results = client.catalog_search("panda bear").await;
|
||||||
|
assert!(!results.tracks.is_empty(), "search returned no songs");
|
||||||
|
assert!(
|
||||||
|
results.tracks.iter().all(|t| t.artist_id.is_some()),
|
||||||
|
"every Deezer search row carries an artist id",
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows: Vec<(Option<u64>, String)> = results
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.map(|t| (t.artist_id, t.title.clone()))
|
||||||
|
.collect();
|
||||||
|
let counts = client.track_listeners(&rows).await;
|
||||||
|
assert_eq!(counts.len(), rows.len(), "one answer per row, in order");
|
||||||
|
|
||||||
|
for (track, listeners) in results.tracks.iter().zip(&counts) {
|
||||||
|
println!(
|
||||||
|
"{:>12} {:<52} {}",
|
||||||
|
listeners
|
||||||
|
.map(|n| n.to_string())
|
||||||
|
.unwrap_or_else(|| "—".into()),
|
||||||
|
track.title,
|
||||||
|
track.artist,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
counts.iter().any(|c| c.is_some()),
|
||||||
|
"no row resolved a listener count at all",
|
||||||
|
);
|
||||||
|
// The point of the whole change: a row the searched-for artist only
|
||||||
|
// guests on is still answered, from ITS OWN artist's Last.fm page.
|
||||||
|
let searched = results.tracks.first().map(|t| t.artist_id).unwrap();
|
||||||
|
let cross: Vec<_> = results
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.zip(&counts)
|
||||||
|
.filter(|(t, _)| t.artist_id != searched)
|
||||||
|
.collect();
|
||||||
|
if let Some((track, _)) = cross.first() {
|
||||||
|
assert!(
|
||||||
|
cross.iter().any(|(_, c)| c.is_some()),
|
||||||
|
"no cross-artist row resolved a count (e.g. {} by {})",
|
||||||
|
track.title,
|
||||||
|
track.artist,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Last.fm returns a popular song many times over — once as itself and
|
/// Last.fm returns a popular song many times over — once as itself and
|
||||||
/// again for every "(Album Version)", live cut and remaster — listeners
|
/// again for every "(Album Version)", live cut and remaster — listeners
|
||||||
/// descending. The canonical first entry must win.
|
/// descending. The canonical first entry must win.
|
||||||
@@ -15105,6 +15330,8 @@ mod tests {
|
|||||||
id,
|
id,
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
artist: "A".into(),
|
artist: "A".into(),
|
||||||
|
artist_id: None,
|
||||||
|
rank: None,
|
||||||
album: "B".into(),
|
album: "B".into(),
|
||||||
album_id: Some(1),
|
album_id: Some(1),
|
||||||
cover: None,
|
cover: None,
|
||||||
|
|||||||
@@ -2533,6 +2533,46 @@ pub async fn catalog_artist_popular(
|
|||||||
.ok_or_else(|| "artist popular lookup failed".to_string())
|
.ok_or_else(|| "artist popular lookup failed".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Genres and Last.fm totals for the artist a search is about — the search
|
||||||
|
/// hero's stat line and tag chips. Deferred like every other supplement: it
|
||||||
|
/// rides MusicBrainz's gate and must never hold up the results.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn catalog_artist_profile(
|
||||||
|
state: State<'_, LibraryState>,
|
||||||
|
artist_id: u64,
|
||||||
|
) -> Result<metadata::CatalogArtistProfile, String> {
|
||||||
|
state.touch_catalog_activity();
|
||||||
|
Ok(state.metadata.artist_profile(artist_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One search-result row, reduced to what a listener lookup needs.
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TrackListenerQuery {
|
||||||
|
/// The PERFORMING artist's Deezer id. `None` for a row the provider gave no
|
||||||
|
/// artist id for, which simply gets no count — never a guess from the name.
|
||||||
|
pub artist_id: Option<u64>,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last.fm listener counts for a set of search-result rows, answered in order.
|
||||||
|
///
|
||||||
|
/// Deferred on purpose: `catalog_search` is typed against per keystroke and must
|
||||||
|
/// stay three Deezer calls, so the counts are asked for separately once the
|
||||||
|
/// results are on screen. See `MetadataClient::track_listeners`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn catalog_track_listeners(
|
||||||
|
state: State<'_, LibraryState>,
|
||||||
|
tracks: Vec<TrackListenerQuery>,
|
||||||
|
) -> Result<Vec<Option<u64>>, String> {
|
||||||
|
state.touch_catalog_activity();
|
||||||
|
let rows: Vec<(Option<u64>, String)> = tracks
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| (t.artist_id, t.title))
|
||||||
|
.collect();
|
||||||
|
Ok(state.metadata.track_listeners(&rows).await)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn catalog_related(
|
pub async fn catalog_related(
|
||||||
state: State<'_, LibraryState>,
|
state: State<'_, LibraryState>,
|
||||||
@@ -4217,6 +4257,21 @@ pub async fn waveform(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the audio thread is doing right now.
|
||||||
|
///
|
||||||
|
/// The webview learns about playback only from events, so a RELOAD leaves it
|
||||||
|
/// believing nothing is playing while audio carries on — the scrubber moved
|
||||||
|
/// under the words "Nothing playing". This is the boot-time answer, the same
|
||||||
|
/// shape of recovery `app_status` already provides for the Soulseek connection.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn player_state(players: State<'_, PlayerState>) -> crate::playback::PlaybackSnapshot {
|
||||||
|
players
|
||||||
|
.last_transport
|
||||||
|
.lock()
|
||||||
|
.map(|snapshot| snapshot.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Player transport: action ∈ pause | resume | stop, or seek/volume with value.
|
/// Player transport: action ∈ pause | resume | stop, or seek/volume with value.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn player_control(
|
pub fn player_control(
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ pub fn run() {
|
|||||||
commands::play_local,
|
commands::play_local,
|
||||||
commands::preload_local,
|
commands::preload_local,
|
||||||
commands::player_control,
|
commands::player_control,
|
||||||
|
commands::player_state,
|
||||||
commands::waveform,
|
commands::waveform,
|
||||||
commands::library_albums,
|
commands::library_albums,
|
||||||
commands::album_tracks,
|
commands::album_tracks,
|
||||||
@@ -100,6 +101,8 @@ pub fn run() {
|
|||||||
commands::pick_artist_hero,
|
commands::pick_artist_hero,
|
||||||
commands::clear_artist_hero,
|
commands::clear_artist_hero,
|
||||||
commands::catalog_related,
|
commands::catalog_related,
|
||||||
|
commands::catalog_track_listeners,
|
||||||
|
commands::catalog_artist_profile,
|
||||||
commands::catalog_album,
|
commands::catalog_album,
|
||||||
commands::catalog_album_info,
|
commands::catalog_album_info,
|
||||||
commands::catalog_art,
|
commands::catalog_art,
|
||||||
|
|||||||
Reference in new issue
Block a user