From 6e156037e6215c0e673c987256a6afe6c9dd21cb Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Wed, 25 Mar 2026 15:48:37 -0400 Subject: [PATCH] top songs flow --- frontend/src/pages/artist.rs | 158 +++++++++++++++++++++++++++++++++++ frontend/src/types.rs | 13 +++ src/routes/artists.rs | 85 ++++++++++++++++++- 3 files changed, 255 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/artist.rs b/frontend/src/pages/artist.rs index 737b90d..721eac5 100644 --- a/frontend/src/pages/artist.rs +++ b/frontend/src/pages/artist.rs @@ -5,6 +5,7 @@ use yew::prelude::*; use yew_router::prelude::*; use crate::api; +use crate::components::status_badge::StatusBadge; use crate::pages::Route; use crate::types::FullArtistDetail; @@ -18,6 +19,8 @@ pub fn artist_page(props: &Props) -> Html { let detail = use_state(|| None::); let error = use_state(|| None::); let message = use_state(|| None::); + let active_tab = use_state(|| "discography".to_string()); + let top_songs_limit = use_state(|| 25usize); let id = props.id.clone(); // Flag to prevent the background enrichment from overwriting a user-triggered refresh @@ -259,6 +262,151 @@ pub fn artist_page(props: &Props) -> Html { } }; + let tab_bar_html = { + let has_top_songs = d.lastfm_available && !d.top_songs.is_empty(); + let disco_active = *active_tab == "discography"; + let top_active = *active_tab == "top_songs"; + let on_disco = { + let tab = active_tab.clone(); + Callback::from(move |_: MouseEvent| tab.set("discography".to_string())) + }; + let on_top = { + let tab = active_tab.clone(); + Callback::from(move |_: MouseEvent| tab.set("top_songs".to_string())) + }; + html! { +
+ + if has_top_songs { + + } +
+ } + }; + + let top_songs_html = { + let limit = *top_songs_limit; + let visible: Vec<_> = d.top_songs.iter().take(limit).collect(); + let has_more = d.top_songs.len() > limit; + let artist_name = d.artist.name.clone(); + + let on_show_more = { + let top_songs_limit = top_songs_limit.clone(); + Callback::from(move |_: MouseEvent| { + top_songs_limit.set(*top_songs_limit + 25); + }) + }; + + html! { +
+ + + + + + + + + + + + { for visible.iter().enumerate().map(|(i, song)| { + let rank = i + 1; + let has_status = song.status.is_some(); + + let on_watch = { + let detail = detail.clone(); + let name = song.name.clone(); + let mbid = song.mbid.clone(); + let artist = artist_name.clone(); + Callback::from(move |_: MouseEvent| { + let detail = detail.clone(); + let name = name.clone(); + let mbid = mbid.clone(); + let artist = artist.clone(); + wasm_bindgen_futures::spawn_local(async move { + if let Ok(resp) = api::watch_track(Some(&artist), &name, mbid.as_deref().unwrap_or("")).await { + if let Some(ref d) = *detail { + let mut updated = d.clone(); + if let Some(s) = updated.top_songs.get_mut(i) { + s.status = Some(resp.status); + } + detail.set(Some(updated)); + } + } + }); + }) + }; + + let on_unwatch = { + let detail = detail.clone(); + let mbid = song.mbid.clone(); + Callback::from(move |_: MouseEvent| { + let detail = detail.clone(); + let mbid = mbid.clone(); + wasm_bindgen_futures::spawn_local(async move { + if let Some(ref m) = mbid { + if api::unwatch_track(m).await.is_ok() { + if let Some(ref d) = *detail { + let mut updated = d.clone(); + if let Some(s) = updated.top_songs.get_mut(i) { + s.status = None; + } + detail.set(Some(updated)); + } + } + } + }); + }) + }; + + let plays = if song.playcount >= 1_000_000 { + format!("{}M", song.playcount / 1_000_000) + } else if song.playcount >= 1_000 { + format!("{}K", song.playcount / 1_000) + } else { + song.playcount.to_string() + }; + + html! { + + + + + + + + } + })} + +
{ "#" }{ "Title" }{ "Plays" }{ "Status" }
{ rank }{ &song.name }{ plays } + if let Some(ref status) = song.status { + + } + + if !has_status { + + } else { + + } +
+ if has_more { +
+ +
+ } +
+ } + }; + html! {
Html {
} + // Tab bar + { tab_bar_html } + + if *active_tab == "top_songs" && !d.top_songs.is_empty() { + // Top Songs tab content + { top_songs_html } + } else { + if d.albums.is_empty() {

{ "No releases found on MusicBrainz." }

} @@ -575,6 +731,8 @@ pub fn artist_page(props: &Props) -> Html { } })} + + } // close else (discography tab)
} } diff --git a/frontend/src/types.rs b/frontend/src/types.rs index 295f47b..dd6cda9 100644 --- a/frontend/src/types.rs +++ b/frontend/src/types.rs @@ -77,6 +77,19 @@ pub struct FullArtistDetail { pub artist_bio: Option, #[serde(default)] pub artist_banner: Option, + #[serde(default)] + pub top_songs: Vec, + #[serde(default)] + pub lastfm_available: bool, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct TopSongFe { + pub name: String, + #[serde(default)] + pub playcount: u64, + pub mbid: Option, + pub status: Option, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] diff --git a/src/routes/artists.rs b/src/routes/artists.rs index 18443f9..0871e54 100644 --- a/src/routes/artists.rs +++ b/src/routes/artists.rs @@ -2,7 +2,7 @@ use actix_session::Session; use actix_web::{HttpResponse, web}; use serde::{Deserialize, Serialize}; -use shanty_data::{ArtistBioFetcher, ArtistImageFetcher, MetadataFetcher}; +use shanty_data::{ArtistBioFetcher, ArtistImageFetcher, MetadataFetcher, SimilarArtistFetcher}; use shanty_db::entities::wanted_item::WantedStatus; use shanty_db::queries; use shanty_search::SearchProvider; @@ -383,6 +383,87 @@ pub async fn enrich_artist( .await; tracing::debug!(mbid = %mbid, has_photo = artist_photo.is_some(), has_bio = artist_bio.is_some(), has_banner = artist_banner.is_some(), "artist enrichment data"); + // Fetch top songs from Last.fm (cached 7 days) + let top_songs: Vec = if let Some(ref key) = lastfm_api_key { + let cache_key = format!("lastfm_top_tracks:{mbid}"); + let cached: Option> = + if let Ok(Some(json)) = queries::cache::get(state.db.conn(), &cache_key).await { + serde_json::from_str(&json).ok() + } else { + None + }; + + let tracks = if let Some(cached) = cached { + cached + } else { + match shanty_data::LastFmSimilarFetcher::new(key.clone()) { + Ok(fetcher) => { + match fetcher.get_top_tracks(&artist.name, Some(&mbid)).await { + Ok(tracks) => { + // Cache for 7 days + if let Ok(json) = serde_json::to_string(&tracks) { + let _ = queries::cache::set( + state.db.conn(), + &cache_key, + "lastfm", + &json, + 7 * 86400, + ) + .await; + // Also persist on artist record + if let Some(local_id) = id { + let _ = queries::artists::update_top_songs( + state.db.conn(), + local_id, + &json, + ) + .await; + } + } + tracks + } + Err(e) => { + tracing::warn!(error = %e, "failed to fetch Last.fm top tracks"); + vec![] + } + } + } + Err(e) => { + tracing::warn!(error = %e, "failed to create Last.fm fetcher"); + vec![] + } + } + }; + + // Cross-reference with wanted items to add status + let all_wanted = queries::wanted::list(state.db.conn(), None, None).await?; + tracks + .iter() + .map(|t| { + let status = t.mbid.as_deref().and_then(|track_mbid| { + all_wanted + .iter() + .find(|w| w.musicbrainz_id.as_deref() == Some(track_mbid)) + .map(|w| match w.status { + WantedStatus::Owned => "owned", + WantedStatus::Downloaded => "downloaded", + WantedStatus::Wanted => "wanted", + WantedStatus::Available => "available", + }) + }); + serde_json::json!({ + "name": t.name, + "playcount": t.playcount, + "mbid": t.mbid, + "status": status, + }) + }) + .collect() + } else { + vec![] + }; + let lastfm_available = lastfm_api_key.is_some(); + // Fetch release groups and split into primary vs featured let all_release_groups = state .search @@ -656,6 +737,8 @@ pub async fn enrich_artist( "artist_photo": artist_photo, "artist_bio": artist_bio, "artist_banner": artist_banner, + "top_songs": top_songs, + "lastfm_available": lastfm_available, })) }