I **think** I've at least 99% fixed the top songs mismatch
This commit is contained in:
@@ -21,6 +21,11 @@ pub fn artist_page(props: &Props) -> Html {
|
||||
let message = use_state(|| None::<String>);
|
||||
let active_tab = use_state(|| "discography".to_string());
|
||||
let top_songs_limit = use_state(|| 25usize);
|
||||
// Track top song status overrides — use Rc<RefCell> so async callbacks always see latest state
|
||||
let top_song_overrides: Rc<std::cell::RefCell<std::collections::HashMap<usize, Option<String>>>> =
|
||||
use_mut_ref(std::collections::HashMap::new);
|
||||
// Counter to force re-renders when overrides change
|
||||
let overrides_version = use_state(|| 0u32);
|
||||
let id = props.id.clone();
|
||||
|
||||
// Flag to prevent the background enrichment from overwriting a user-triggered refresh
|
||||
@@ -314,50 +319,65 @@ pub fn artist_page(props: &Props) -> Html {
|
||||
<tbody>
|
||||
{ for visible.iter().enumerate().map(|(i, song)| {
|
||||
let rank = i + 1;
|
||||
let has_status = song.status.is_some();
|
||||
// Use override status if set, otherwise use the original
|
||||
let effective_status = top_song_overrides
|
||||
.borrow()
|
||||
.get(&i)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| song.status.clone());
|
||||
let has_status = effective_status.is_some();
|
||||
// Check if this song is in-flight (override set to exactly "pending")
|
||||
let is_pending = top_song_overrides
|
||||
.borrow()
|
||||
.get(&i)
|
||||
.map(|s| s.as_deref() == Some("pending"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let on_watch = {
|
||||
let detail = detail.clone();
|
||||
let overrides = top_song_overrides.clone();
|
||||
let version = overrides_version.clone();
|
||||
let name = song.name.clone();
|
||||
let mbid = song.mbid.clone();
|
||||
let artist = artist_name.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
let detail = detail.clone();
|
||||
// Immediately mark as pending
|
||||
overrides.borrow_mut().insert(i, Some("pending".to_string()));
|
||||
version.set(*version + 1);
|
||||
|
||||
let overrides = overrides.clone();
|
||||
let version = version.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 status = match api::watch_track(Some(&artist), &name, mbid.as_deref().unwrap_or("")).await {
|
||||
Ok(resp) => resp.status,
|
||||
Err(_) => "wanted".to_string(),
|
||||
};
|
||||
overrides.borrow_mut().insert(i, Some(status));
|
||||
version.set(*version + 1);
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
let on_unwatch = {
|
||||
let detail = detail.clone();
|
||||
let overrides = top_song_overrides.clone();
|
||||
let version = overrides_version.clone();
|
||||
let mbid = song.mbid.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
let detail = detail.clone();
|
||||
// Immediately mark as pending
|
||||
overrides.borrow_mut().insert(i, Some("pending".to_string()));
|
||||
version.set(*version + 1);
|
||||
|
||||
let overrides = overrides.clone();
|
||||
let version = version.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 _ = api::unwatch_track(m).await;
|
||||
}
|
||||
overrides.borrow_mut().insert(i, None);
|
||||
version.set(*version + 1);
|
||||
});
|
||||
})
|
||||
};
|
||||
@@ -376,12 +396,16 @@ pub fn artist_page(props: &Props) -> Html {
|
||||
<td>{ &song.name }</td>
|
||||
<td class="text-muted text-sm">{ plays }</td>
|
||||
<td>
|
||||
if let Some(ref status) = song.status {
|
||||
if let Some(ref status) = effective_status {
|
||||
if status != "pending" {
|
||||
<StatusBadge status={status.clone()} />
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td class="actions">
|
||||
if !has_status {
|
||||
if is_pending {
|
||||
<button class="btn btn-sm" disabled={true}>{ "..." }</button>
|
||||
} else if !has_status {
|
||||
<button class="btn btn-sm" onclick={on_watch}>
|
||||
{ "Watch" }
|
||||
</button>
|
||||
|
||||
@@ -118,11 +118,18 @@ async fn list_artists(
|
||||
.iter()
|
||||
.filter(|w| w.artist_id == Some(a.id))
|
||||
.collect();
|
||||
let total_watched = artist_wanted.len();
|
||||
// Deduplicate by MBID to match the detail page's counting logic
|
||||
let total_watched = artist_wanted
|
||||
.iter()
|
||||
.filter_map(|w| w.musicbrainz_id.as_deref())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len();
|
||||
let total_owned = artist_wanted
|
||||
.iter()
|
||||
.filter(|w| w.status == WantedStatus::Owned)
|
||||
.count();
|
||||
.filter_map(|w| w.musicbrainz_id.as_deref())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len();
|
||||
|
||||
items.push(ArtistListItem {
|
||||
id: a.id,
|
||||
@@ -435,34 +442,41 @@ pub async fn enrich_artist(
|
||||
}
|
||||
};
|
||||
|
||||
// Cross-reference with wanted items to add status (by MBID or by name for this artist)
|
||||
// Cross-reference with wanted items to add status.
|
||||
// Resolve each top song title → discography recording MBID → wanted_item.
|
||||
// This uses the same fuzzy-match + album-preference logic as add_track,
|
||||
// so the MBID is guaranteed to match a recording on the discography page.
|
||||
let all_wanted = queries::wanted::list(state.db.conn(), None, None).await?;
|
||||
let artist_wanted: Vec<_> = all_wanted
|
||||
let wanted_by_mbid: std::collections::HashMap<
|
||||
&str,
|
||||
&shanty_db::entities::wanted_item::Model,
|
||||
> = all_wanted
|
||||
.iter()
|
||||
.filter(|w| id.is_some() && w.artist_id == id)
|
||||
.filter_map(|w| w.musicbrainz_id.as_deref().map(|m| (m, w)))
|
||||
.collect();
|
||||
|
||||
// Load the discography cache for fuzzy title → MBID resolution
|
||||
let disc_recordings: Vec<shanty_watch::DiscRecording> =
|
||||
if let Some(ref artist_mbid) = artist.musicbrainz_id {
|
||||
let cache_key = format!("artist_known_recordings:{artist_mbid}");
|
||||
if let Ok(Some(json)) = queries::cache::get(state.db.conn(), &cache_key).await {
|
||||
serde_json::from_str(&json).unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
tracks
|
||||
.iter()
|
||||
.map(|t| {
|
||||
// Try matching by MBID first
|
||||
let status = t
|
||||
.mbid
|
||||
.as_deref()
|
||||
.and_then(|track_mbid| {
|
||||
all_wanted
|
||||
.iter()
|
||||
.find(|w| w.musicbrainz_id.as_deref() == Some(track_mbid))
|
||||
})
|
||||
// Fall back to matching by title (case-insensitive) within this artist's wanted items
|
||||
.or_else(|| {
|
||||
let name_lower = t.name.to_lowercase();
|
||||
artist_wanted
|
||||
.iter()
|
||||
.find(|w| w.name.to_lowercase() == name_lower)
|
||||
.copied()
|
||||
})
|
||||
.map(|w| match w.status {
|
||||
// Resolve the top song title to a discography MBID, then look up the wanted item
|
||||
let matched = shanty_watch::resolve_from_discography(&t.name, &disc_recordings)
|
||||
.and_then(|disc| wanted_by_mbid.get(disc.mbid.as_str()).copied());
|
||||
|
||||
let status = matched.map(|w| match w.status {
|
||||
WantedStatus::Owned => "owned",
|
||||
WantedStatus::Downloaded => "downloaded",
|
||||
WantedStatus::Wanted => "wanted",
|
||||
@@ -561,6 +575,7 @@ pub async fn enrich_artist(
|
||||
let mut seen_watched: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut seen_owned: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut albums: Vec<FullAlbumInfo> = Vec::new();
|
||||
let mut disc_recordings: Vec<shanty_watch::DiscRecording> = Vec::new();
|
||||
|
||||
for rg in &release_groups {
|
||||
if skip_track_fetch {
|
||||
@@ -627,9 +642,20 @@ pub async fn enrich_artist(
|
||||
let mut owned: u32 = 0;
|
||||
let mut downloaded: u32 = 0;
|
||||
|
||||
let rg_type = rg.primary_type.clone().unwrap_or_default();
|
||||
let rg_date = rg.first_release_date.clone();
|
||||
|
||||
for track in &cached.tracks {
|
||||
let rec_id = &track.recording_mbid;
|
||||
|
||||
// Collect for known_recordings cache rebuild
|
||||
disc_recordings.push(shanty_watch::DiscRecording {
|
||||
mbid: rec_id.clone(),
|
||||
title: track.title.clone(),
|
||||
rg_type: rg_type.clone(),
|
||||
rg_date: rg_date.clone(),
|
||||
});
|
||||
|
||||
// Add to artist-level unique available set
|
||||
seen_available.insert(rec_id.clone());
|
||||
|
||||
@@ -689,6 +715,17 @@ pub async fn enrich_artist(
|
||||
});
|
||||
}
|
||||
|
||||
// Rebuild the known_recordings cache from the detail page's actual track data.
|
||||
// This ensures add_track's fast path uses MBIDs that match the displayed release groups.
|
||||
if !skip_track_fetch
|
||||
&& !disc_recordings.is_empty()
|
||||
&& let Ok(json) = serde_json::to_string(&disc_recordings)
|
||||
{
|
||||
let cache_key = format!("artist_known_recordings:{mbid}");
|
||||
let _ =
|
||||
queries::cache::set(state.db.conn(), &cache_key, "computed", &json, 7 * 86400).await;
|
||||
}
|
||||
|
||||
// Sort: owned first, then partial, then wanted, then unwatched; within each by date
|
||||
albums.sort_by(|a, b| {
|
||||
let order = |s: &str| match s {
|
||||
|
||||
@@ -417,18 +417,32 @@ async fn process_tag(
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Ensure a wanted_item exists for this track (marks imported files as Owned)
|
||||
if let Some(ref mbid) = track.musicbrainz_id
|
||||
&& queries::wanted::find_by_mbid(conn, mbid)
|
||||
// Check by MBID first, then by name+artist to avoid duplicates from MBID mismatches
|
||||
let has_wanted = if let Some(ref mbid) = track.musicbrainz_id {
|
||||
queries::wanted::find_by_mbid(conn, mbid)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.is_none()
|
||||
{
|
||||
.is_some()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let has_wanted = has_wanted || {
|
||||
// Also check by name + artist_id (normalize unicode dashes and case)
|
||||
let all_wanted = queries::wanted::list(conn, None, None)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let title_norm = normalize_for_match(track.title.as_deref().unwrap_or(""));
|
||||
all_wanted
|
||||
.iter()
|
||||
.any(|w| w.artist_id == track.artist_id && normalize_for_match(&w.name) == title_norm)
|
||||
};
|
||||
if !has_wanted {
|
||||
let item = queries::wanted::add(
|
||||
conn,
|
||||
queries::wanted::AddWantedItem {
|
||||
item_type: shanty_db::entities::wanted_item::ItemType::Track,
|
||||
name: track.title.as_deref().unwrap_or("Unknown"),
|
||||
musicbrainz_id: Some(mbid),
|
||||
musicbrainz_id: track.musicbrainz_id.as_deref(),
|
||||
artist_id: track.artist_id,
|
||||
album_id: track.album_id,
|
||||
track_id: Some(track.id),
|
||||
@@ -574,3 +588,16 @@ async fn process_enrich(
|
||||
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// Normalize a string for fuzzy matching: lowercase, replace unicode dashes/quotes with ASCII.
|
||||
fn normalize_for_match(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
.replace(
|
||||
[
|
||||
'\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}',
|
||||
],
|
||||
"-",
|
||||
)
|
||||
.replace(['\u{2018}', '\u{2019}'], "'")
|
||||
.replace(['\u{201C}', '\u{201D}'], "\"")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user