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:
KeelrLeekandClaude Opus 5 committed 2026-08-03 07:03:03 -05:00
1 parent f8f56f4398
commit dc918c98ef
3 files changed
+285

No files matched your search

+227
View File
@@ -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,
+55
View File
@@ -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(
+3
View File
@@ -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,