top songs flow

This commit is contained in:
Connor Johnstone
2026-03-25 15:48:37 -04:00
parent 1a478dea8e
commit 6e156037e6
3 changed files with 255 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ use yew::prelude::*;
use yew_router::prelude::*; use yew_router::prelude::*;
use crate::api; use crate::api;
use crate::components::status_badge::StatusBadge;
use crate::pages::Route; use crate::pages::Route;
use crate::types::FullArtistDetail; use crate::types::FullArtistDetail;
@@ -18,6 +19,8 @@ pub fn artist_page(props: &Props) -> Html {
let detail = use_state(|| None::<FullArtistDetail>); let detail = use_state(|| None::<FullArtistDetail>);
let error = use_state(|| None::<String>); let error = use_state(|| None::<String>);
let message = use_state(|| None::<String>); let message = use_state(|| None::<String>);
let active_tab = use_state(|| "discography".to_string());
let top_songs_limit = use_state(|| 25usize);
let id = props.id.clone(); let id = props.id.clone();
// Flag to prevent the background enrichment from overwriting a user-triggered refresh // 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! {
<div class="tab-bar">
<button class={if disco_active { "tab-btn active" } else { "tab-btn" }}
onclick={on_disco}>{ "Discography" }</button>
if has_top_songs {
<button class={if top_active { "tab-btn active" } else { "tab-btn" }}
onclick={on_top}>{ "Top Songs" }</button>
}
</div>
}
};
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! {
<div>
<table>
<thead>
<tr>
<th style="width:3rem;">{ "#" }</th>
<th>{ "Title" }</th>
<th>{ "Plays" }</th>
<th>{ "Status" }</th>
<th></th>
</tr>
</thead>
<tbody>
{ 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! {
<tr>
<td class="text-muted">{ rank }</td>
<td>{ &song.name }</td>
<td class="text-muted text-sm">{ plays }</td>
<td>
if let Some(ref status) = song.status {
<StatusBadge status={status.clone()} />
}
</td>
<td class="actions">
if !has_status {
<button class="btn btn-sm" onclick={on_watch}>
{ "Watch" }
</button>
} else {
<button class="btn btn-sm btn-secondary" onclick={on_unwatch}>
{ "Unwatch" }
</button>
}
</td>
</tr>
}
})}
</tbody>
</table>
if has_more {
<div style="text-align:center;margin-top:0.5rem;">
<button class="btn btn-sm btn-secondary" onclick={on_show_more}>
{ format!("Show More ({} total)", d.top_songs.len()) }
</button>
</div>
}
</div>
}
};
html! { html! {
<div> <div>
<div class={if d.artist_banner.is_some() { "artist-banner-wrap" } else { "" }} <div class={if d.artist_banner.is_some() { "artist-banner-wrap" } else { "" }}
@@ -343,6 +491,14 @@ pub fn artist_page(props: &Props) -> Html {
</div> </div>
} }
// 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() { if d.albums.is_empty() {
<p class="text-muted">{ "No releases found on MusicBrainz." }</p> <p class="text-muted">{ "No releases found on MusicBrainz." }</p>
} }
@@ -575,6 +731,8 @@ pub fn artist_page(props: &Props) -> Html {
</details> </details>
} }
})} })}
} // close else (discography tab)
</div> </div>
} }
} }

View File

@@ -77,6 +77,19 @@ pub struct FullArtistDetail {
pub artist_bio: Option<String>, pub artist_bio: Option<String>,
#[serde(default)] #[serde(default)]
pub artist_banner: Option<String>, pub artist_banner: Option<String>,
#[serde(default)]
pub top_songs: Vec<TopSongFe>,
#[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<String>,
pub status: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]

View File

@@ -2,7 +2,7 @@ use actix_session::Session;
use actix_web::{HttpResponse, web}; use actix_web::{HttpResponse, web};
use serde::{Deserialize, Serialize}; 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::entities::wanted_item::WantedStatus;
use shanty_db::queries; use shanty_db::queries;
use shanty_search::SearchProvider; use shanty_search::SearchProvider;
@@ -383,6 +383,87 @@ pub async fn enrich_artist(
.await; .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"); 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<serde_json::Value> = if let Some(ref key) = lastfm_api_key {
let cache_key = format!("lastfm_top_tracks:{mbid}");
let cached: Option<Vec<shanty_data::PopularTrack>> =
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 // Fetch release groups and split into primary vs featured
let all_release_groups = state let all_release_groups = state
.search .search
@@ -656,6 +737,8 @@ pub async fn enrich_artist(
"artist_photo": artist_photo, "artist_photo": artist_photo,
"artist_bio": artist_bio, "artist_bio": artist_bio,
"artist_banner": artist_banner, "artist_banner": artist_banner,
"top_songs": top_songs,
"lastfm_available": lastfm_available,
})) }))
} }