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 title: 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_id: Option<u64>,
|
||||
pub cover: Option<String>,
|
||||
@@ -647,7 +661,19 @@ struct ArtistSupplements {
|
||||
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)]
|
||||
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 artists: Vec<CatalogArtist>,
|
||||
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
|
||||
/// 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
|
||||
@@ -5572,6 +5713,8 @@ impl MetadataClient {
|
||||
id: synthetic_catalog_id(&format!("track:{source_id}")),
|
||||
title,
|
||||
artist: source.artist.clone(),
|
||||
artist_id: None,
|
||||
rank: None,
|
||||
album: source.title.clone(),
|
||||
album_id: Some(album_id),
|
||||
cover: source.cover.clone(),
|
||||
@@ -5743,6 +5886,8 @@ impl MetadataClient {
|
||||
id: synthetic_catalog_id(&format!("apple-track:{track_id}")),
|
||||
title,
|
||||
artist: source.artist.clone(),
|
||||
artist_id: None,
|
||||
rank: None,
|
||||
album: source.title.clone(),
|
||||
album_id: Some(album_id),
|
||||
cover: source.cover.clone(),
|
||||
@@ -7790,6 +7935,8 @@ fn parse_track(v: &serde_json::Value) -> Option<CatalogTrack> {
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or_default()
|
||||
.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
|
||||
.pointer("/album/title")
|
||||
.and_then(|a| a.as_str())
|
||||
@@ -8555,6 +8702,8 @@ fn build_popular(
|
||||
// scrobbled string, which carries whatever users typed.
|
||||
title: recording.title.clone(),
|
||||
artist: artist.to_string(),
|
||||
artist_id: None,
|
||||
rank: None,
|
||||
album: recording.album_title.clone(),
|
||||
// The SAME synthetic id the album supplement mints for this release
|
||||
// group, so the row points at the card the discography already
|
||||
@@ -12504,6 +12653,82 @@ fn urlencode(s: &str) -> String {
|
||||
#[cfg(test)]
|
||||
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
|
||||
/// again for every "(Album Version)", live cut and remaster — listeners
|
||||
/// descending. The canonical first entry must win.
|
||||
@@ -15105,6 +15330,8 @@ mod tests {
|
||||
id,
|
||||
title: title.to_string(),
|
||||
artist: "A".into(),
|
||||
artist_id: None,
|
||||
rank: None,
|
||||
album: "B".into(),
|
||||
album_id: Some(1),
|
||||
cover: None,
|
||||
|
||||
@@ -2533,6 +2533,46 @@ pub async fn catalog_artist_popular(
|
||||
.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]
|
||||
pub async fn catalog_related(
|
||||
state: State<'_, LibraryState>,
|
||||
@@ -4217,6 +4257,21 @@ pub async fn waveform(
|
||||
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.
|
||||
#[tauri::command]
|
||||
pub fn player_control(
|
||||
|
||||
@@ -59,6 +59,7 @@ pub fn run() {
|
||||
commands::play_local,
|
||||
commands::preload_local,
|
||||
commands::player_control,
|
||||
commands::player_state,
|
||||
commands::waveform,
|
||||
commands::library_albums,
|
||||
commands::album_tracks,
|
||||
@@ -100,6 +101,8 @@ pub fn run() {
|
||||
commands::pick_artist_hero,
|
||||
commands::clear_artist_hero,
|
||||
commands::catalog_related,
|
||||
commands::catalog_track_listeners,
|
||||
commands::catalog_artist_profile,
|
||||
commands::catalog_album,
|
||||
commands::catalog_album_info,
|
||||
commands::catalog_art,
|
||||
|
||||
Reference in new issue
Block a user