diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index 82898e9..fc63824 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -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, + /// 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, pub album: String, pub album_id: Option, pub cover: Option, @@ -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, + /// All-time Last.fm scrobbles for the artist. + pub plays: Option, + /// MusicBrainz's most-voted genre tags, strongest first. + pub tags: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] pub struct CatalogSearch { pub artists: Vec, pub albums: Vec, @@ -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::().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, String)]) -> Vec> { + // 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 = 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)> = + 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 = 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 { .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, 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, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1776e59..97bf8e5 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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 { + 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, + 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, +) -> Result>, String> { + state.touch_catalog_activity(); + let rows: Vec<(Option, 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( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ded732e..ee4efeb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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,