I **think** I've at least 99% fixed the top songs mismatch

This commit is contained in:
Connor Johnstone
2026-03-25 21:50:09 -04:00
parent 1a890b0c11
commit 159cdda386
3 changed files with 146 additions and 58 deletions

View File

@@ -21,6 +21,11 @@ pub fn artist_page(props: &Props) -> Html {
let message = use_state(|| None::<String>); let message = use_state(|| None::<String>);
let active_tab = use_state(|| "discography".to_string()); let active_tab = use_state(|| "discography".to_string());
let top_songs_limit = use_state(|| 25usize); 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(); 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
@@ -314,50 +319,65 @@ pub fn artist_page(props: &Props) -> Html {
<tbody> <tbody>
{ for visible.iter().enumerate().map(|(i, song)| { { for visible.iter().enumerate().map(|(i, song)| {
let rank = i + 1; 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 on_watch = {
let detail = detail.clone(); let overrides = top_song_overrides.clone();
let version = overrides_version.clone();
let name = song.name.clone(); let name = song.name.clone();
let mbid = song.mbid.clone(); let mbid = song.mbid.clone();
let artist = artist_name.clone(); let artist = artist_name.clone();
Callback::from(move |_: MouseEvent| { 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 name = name.clone();
let mbid = mbid.clone(); let mbid = mbid.clone();
let artist = artist.clone(); let artist = artist.clone();
wasm_bindgen_futures::spawn_local(async move { wasm_bindgen_futures::spawn_local(async move {
if let Ok(resp) = api::watch_track(Some(&artist), &name, mbid.as_deref().unwrap_or("")).await { let status = match api::watch_track(Some(&artist), &name, mbid.as_deref().unwrap_or("")).await {
if let Some(ref d) = *detail { Ok(resp) => resp.status,
let mut updated = d.clone(); Err(_) => "wanted".to_string(),
if let Some(s) = updated.top_songs.get_mut(i) { };
s.status = Some(resp.status); overrides.borrow_mut().insert(i, Some(status));
} version.set(*version + 1);
detail.set(Some(updated));
}
}
}); });
}) })
}; };
let on_unwatch = { let on_unwatch = {
let detail = detail.clone(); let overrides = top_song_overrides.clone();
let version = overrides_version.clone();
let mbid = song.mbid.clone(); let mbid = song.mbid.clone();
Callback::from(move |_: MouseEvent| { 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(); let mbid = mbid.clone();
wasm_bindgen_futures::spawn_local(async move { wasm_bindgen_futures::spawn_local(async move {
if let Some(ref m) = mbid { if let Some(ref m) = mbid {
if api::unwatch_track(m).await.is_ok() { let _ = api::unwatch_track(m).await;
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));
}
}
} }
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>{ &song.name }</td>
<td class="text-muted text-sm">{ plays }</td> <td class="text-muted text-sm">{ plays }</td>
<td> <td>
if let Some(ref status) = song.status { if let Some(ref status) = effective_status {
<StatusBadge status={status.clone()} /> if status != "pending" {
<StatusBadge status={status.clone()} />
}
} }
</td> </td>
<td class="actions"> <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}> <button class="btn btn-sm" onclick={on_watch}>
{ "Watch" } { "Watch" }
</button> </button>

View File

@@ -118,11 +118,18 @@ async fn list_artists(
.iter() .iter()
.filter(|w| w.artist_id == Some(a.id)) .filter(|w| w.artist_id == Some(a.id))
.collect(); .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 let total_owned = artist_wanted
.iter() .iter()
.filter(|w| w.status == WantedStatus::Owned) .filter(|w| w.status == WantedStatus::Owned)
.count(); .filter_map(|w| w.musicbrainz_id.as_deref())
.collect::<std::collections::HashSet<_>>()
.len();
items.push(ArtistListItem { items.push(ArtistListItem {
id: a.id, id: a.id,
@@ -435,39 +442,46 @@ 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 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() .iter()
.filter(|w| id.is_some() && w.artist_id == id) .filter(|w| id.is_some() && w.artist_id == id)
.filter_map(|w| w.musicbrainz_id.as_deref().map(|m| (m, w)))
.collect(); .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 tracks
.iter() .iter()
.map(|t| { .map(|t| {
// Try matching by MBID first // Resolve the top song title to a discography MBID, then look up the wanted item
let status = t let matched = shanty_watch::resolve_from_discography(&t.name, &disc_recordings)
.mbid .and_then(|disc| wanted_by_mbid.get(disc.mbid.as_str()).copied());
.as_deref()
.and_then(|track_mbid| { let status = matched.map(|w| match w.status {
all_wanted WantedStatus::Owned => "owned",
.iter() WantedStatus::Downloaded => "downloaded",
.find(|w| w.musicbrainz_id.as_deref() == Some(track_mbid)) WantedStatus::Wanted => "wanted",
}) WantedStatus::Available => "available",
// 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 {
WantedStatus::Owned => "owned",
WantedStatus::Downloaded => "downloaded",
WantedStatus::Wanted => "wanted",
WantedStatus::Available => "available",
});
serde_json::json!({ serde_json::json!({
"name": t.name, "name": t.name,
"playcount": t.playcount, "playcount": t.playcount,
@@ -561,6 +575,7 @@ pub async fn enrich_artist(
let mut seen_watched: std::collections::HashSet<String> = std::collections::HashSet::new(); 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 seen_owned: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut albums: Vec<FullAlbumInfo> = Vec::new(); let mut albums: Vec<FullAlbumInfo> = Vec::new();
let mut disc_recordings: Vec<shanty_watch::DiscRecording> = Vec::new();
for rg in &release_groups { for rg in &release_groups {
if skip_track_fetch { if skip_track_fetch {
@@ -627,9 +642,20 @@ pub async fn enrich_artist(
let mut owned: u32 = 0; let mut owned: u32 = 0;
let mut downloaded: 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 { for track in &cached.tracks {
let rec_id = &track.recording_mbid; 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 // Add to artist-level unique available set
seen_available.insert(rec_id.clone()); 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 // Sort: owned first, then partial, then wanted, then unwatched; within each by date
albums.sort_by(|a, b| { albums.sort_by(|a, b| {
let order = |s: &str| match s { let order = |s: &str| match s {

View File

@@ -417,18 +417,32 @@ async fn process_tag(
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Ensure a wanted_item exists for this track (marks imported files as Owned) // Ensure a wanted_item exists for this track (marks imported files as Owned)
if let Some(ref mbid) = track.musicbrainz_id // Check by MBID first, then by name+artist to avoid duplicates from MBID mismatches
&& queries::wanted::find_by_mbid(conn, mbid) let has_wanted = if let Some(ref mbid) = track.musicbrainz_id {
queries::wanted::find_by_mbid(conn, mbid)
.await .await
.map_err(|e| e.to_string())? .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( let item = queries::wanted::add(
conn, conn,
queries::wanted::AddWantedItem { queries::wanted::AddWantedItem {
item_type: shanty_db::entities::wanted_item::ItemType::Track, item_type: shanty_db::entities::wanted_item::ItemType::Track,
name: track.title.as_deref().unwrap_or("Unknown"), name: track.title.as_deref().unwrap_or("Unknown"),
musicbrainz_id: Some(mbid), musicbrainz_id: track.musicbrainz_id.as_deref(),
artist_id: track.artist_id, artist_id: track.artist_id,
album_id: track.album_id, album_id: track.album_id,
track_id: Some(track.id), track_id: Some(track.id),
@@ -574,3 +588,16 @@ async fn process_enrich(
Ok(vec![]) 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}'], "\"")
}