fix(qt): favorites and pins tell the truth on every surface

The reported symptom was a 404 un-favoriting an artist. The cause was one
word: the shared client builds its query key as `format!("{}_ids", ty)`,
so it wants the SINGULAR noun, and every Qt call site passed the plural.
`artists_ids=` is not a parameter Qobuz knows, so /favorite/delete
answered 404 -- and /favorite/create had been failing just as silently in
the other direction. The trap is that the SAME identifier takes the
plural for /favorite/getUserFavorites, which is why the migration read
right and was wrong; label reads were the mirror image, sending the
singular where that endpoint wants plural and 400ing on every page open,
so a label could never be unfollowed.

Playlists were worse than wrong -- they went to /favorite/create at all.
That endpoint has no playlist_ids; Slint routes them to library.db and
says so in a comment. Rerouted.

Fixing the write then exposed the display: the toggle read a favourites
cache that only filled after the Library view had been opened once, so on
a fresh launch a favourited album drew an EMPTY heart and the click
REMOVED it. A backend fixed without its display is worse than neither,
because the two now disagree. So every producer stamps its real state at
document-build time from the same authority the write uses, and there is
one settle point every heart reconciles through.

Pinning gets the same treatment plus the fan-out it was missing, and both
now patch badges by signal instead of republishing a document -- a
republish hands `model:` a new array, which resets scroll and tears down
the delegate model.

cc
This commit is contained in:
vicrodh committed 2026-07-29 16:25:12 -06:00
1 parent 82b88ee8e0
commit f3f0acc192
14 files changed
+1865 -184

No files matched your search

+26 -9
View File
@@ -124,21 +124,30 @@ fn canonical_u64(s: &str) -> Option<u64> {
/// scan, which is exactly why the lag shows up with a local album loaded.
///
/// The Slint reference resolves the same truth in O(1) from `fav_cache`'s
/// `HashSet<u64>` (`crates/qbz/src/queue.rs:230`); this is the port of that
/// shape onto the feed we have: one lock, one pass, allocation-free lookups.
/// `HashSet<u64>` (`crates/qbz/src/queue.rs:230`); the port now has that cache
/// (`fav_cache_qt`), so it is the PRIMARY source — the library feed is empty
/// until the user opens the Library view, which is why the now-playing heart
/// used to draw blank on favourited tracks for the whole session. The feed is
/// unioned in because it is the fresher of the two once loaded.
fn favorite_track_ids() -> HashSet<u64> {
crate::library_qt::with_library(|d| {
let mut ids = crate::fav_cache_qt::all_tracks();
if let Some(feed_ids) = crate::library_qt::with_library(|d| {
d.feed
.iter()
.filter(|i| i.is_favorite && i.kind == "track")
.filter_map(|i| canonical_u64(&i.id))
.collect()
})
.unwrap_or_default()
.collect::<HashSet<u64>>()
}) {
ids.extend(feed_ids);
}
ids
}
fn row_from(track: &QueueTrack, favorites: &HashSet<u64>) -> QueueRow {
let is_favorite = favorites.contains(&track.id);
// LOCAL/Plex rows carry a library.db row id, not a Qobuz track id, so a
// numeric collision with the favourite set would light a heart on the
// wrong row — and `toggle_favorite` below refuses to act on those anyway.
let is_favorite = !track.is_local && favorites.contains(&track.id);
let tier = match track.bit_depth {
Some(d) if d >= 24 => "hires",
Some(_) => "cd",
@@ -481,7 +490,7 @@ pub async fn clear_queue(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
/// GUARD: a LOCAL/Plex row's `id` is a library.db row id which is NUMERIC,
/// so `library_qt::is_local_feed_id` (which recognizes local tracks only by
/// their non-numeric FILE-PATH feed ids) would classify it as a Qobuz id and
/// send `add_favorite("tracks", "<db row id>")` — favoriting an unrelated
/// send `add_favorite("track", "<db row id>")` — favoriting an unrelated
/// catalog track. Local hearts have no seam in this port (`local_bulk.rs`
/// documents the same gap), so the mutation is refused here AND the row
/// publishes `isLocal` so the panel can drop the control instead of showing
@@ -504,6 +513,14 @@ pub async fn toggle_favorite(
return;
}
}
let _ = crate::library_qt::toggle_favorite(runtime, kind, id).await;
// Through the shared settle point, not `library_qt::toggle_favorite`
// alone: the queue panel used to swallow the settled value, so a heart
// set from here never reached `libraryFavoriteChanged` (LibraryView /
// AlbumView / ArtistView kept the old glyph) nor the document caches
// (Home / Search / Recommendations re-published the pre-toggle heart on
// their next republish). The panel itself still repaints from `publish`.
if let Some(value) = crate::library_qt::toggle_favorite(runtime, kind, id).await {
crate::emit_library_favorite(kind, id, value);
}
publish(runtime).await;
}