top songs flow
This commit is contained in:
@@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user