From cd4a0494a26444df0578ec89535756b40c33b62c Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:34:48 -0600 Subject: [PATCH] fix(discover): correct artist follow state in the Home/ForYou Pinned carousel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned artist card's follow chip was seeded at build time from FAV_ARTISTS — an in-memory-only set that was always empty at that moment (shell entry warmed tracks/albums/awards, never artists; init_for_user disk-seeded everything but artists) — and nothing re-seeded the pinned model when the follow set later arrived. Every pinned artist showed not-followed, and the toggle read the same empty set, so a second click could genuinely unfollow an artist. Wire artists into the existing disk lifecycle (the qbz-app store already had the table + CRUD): init_for_user seeds from disk, set_all_artists syncs to disk, set_artist mirrors add/remove. Add the shell-entry warm favorite_artist_ids (skipped offline) and re-seed already-built pinned artist rows when it lands (model walk, no rebuild/flicker). km --- crates/qbz/src/fav_cache.rs | 52 ++++++++++++++++++++++++++++++++----- crates/qbz/src/main.rs | 41 +++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/crates/qbz/src/fav_cache.rs b/crates/qbz/src/fav_cache.rs index 3f1ddaf1a..51aef9f04 100644 --- a/crates/qbz/src/fav_cache.rs +++ b/crates/qbz/src/fav_cache.rs @@ -36,10 +36,11 @@ static FAV_ALBUMS: LazyLock>> = static FAV_AWARDS: LazyLock>> = LazyLock::new(|| RwLock::new(HashSet::new())); -/// Process-wide set of the user's followed ARTIST ids (u64). IN-MEMORY ONLY -/// (no per-user disk store): the search + artist-page surfaces re-seed it from -/// `favorite_artist_ids` on load, so the follow heart toggle always has the -/// current state without a per-click network hit. +/// Process-wide set of the user's followed ARTIST ids (u64). Same disk-first +/// + network-refresh lifecycle as the album set (seeded from the per-user +/// store in [`init_for_user`], refreshed by the shell-entry warm and the +/// search/artist-page loaders) — the Pinned carousel's artist follow chip +/// reads it at build time, so it must be correct from first paint and offline. static FAV_ARTISTS: LazyLock>> = LazyLock::new(|| RwLock::new(HashSet::new())); @@ -102,6 +103,22 @@ pub fn init_for_user(base_dir: &Path) { } Err(e) => log::warn!("[qbz-slint] favorites cache award disk seed failed: {e}"), } + match store.get_favorite_artist_ids() { + Ok(ids) => { + let set: HashSet = ids + .into_iter() + .filter_map(|id| u64::try_from(id).ok()) + .collect(); + log::info!( + "[qbz-slint] favorites cache: {} artist ids seeded from disk", + set.len() + ); + if let Ok(mut guard) = FAV_ARTISTS.write() { + *guard = set; + } + } + Err(e) => log::warn!("[qbz-slint] favorites cache artist disk seed failed: {e}"), + } if let Ok(mut guard) = STORE.lock() { *guard = Some(store); } @@ -255,14 +272,25 @@ pub fn is_artist_favorite(artist_id: u64) -> bool { .unwrap_or(false) } -/// Replace the followed-artist set with a freshly-fetched id list (in-memory). +/// Replace the followed-artist set with a freshly-fetched id list and mirror +/// it to the per-user store (full replace — same lifecycle as the album set). +/// Blocking disk write; call off the UI thread. pub fn set_all_artists(ids: HashSet) { + if let Ok(guard) = STORE.lock() { + if let Some(store) = guard.as_ref() { + let disk: Vec = ids.iter().map(|&id| id as i64).collect(); + if let Err(e) = store.sync_favorite_artists(&disk) { + log::warn!("[qbz-slint] favorites cache artist sync failed: {e}"); + } + } + } if let Ok(mut guard) = FAV_ARTISTS.write() { *guard = ids; } } -/// Insert / remove a single artist id (optimistic follow toggle). +/// Insert / remove a single artist id (optimistic follow toggle) and mirror +/// the change to the per-user store so the follow survives a restart. pub fn set_artist(artist_id: u64, favorite: bool) { if let Ok(mut guard) = FAV_ARTISTS.write() { if favorite { @@ -271,6 +299,18 @@ pub fn set_artist(artist_id: u64, favorite: bool) { guard.remove(&artist_id); } } + if let Ok(guard) = STORE.lock() { + if let Some(store) = guard.as_ref() { + let res = if favorite { + store.add_favorite_artist(artist_id as i64) + } else { + store.remove_favorite_artist(artist_id as i64) + }; + if let Err(e) = res { + log::warn!("[qbz-slint] favorites cache artist disk update failed: {e}"); + } + } + } } // ==================== Followed awards ==================== diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index 2bf5128a4..b7937d78b 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -451,6 +451,47 @@ async fn enter_shell( }); } + // Same for followed ARTISTS — the Pinned carousel's artist follow chip + // seeds from fav_cache at build time (its only build-time consumer), and + // the pinned model is built BEFORE this warm lands: re-seed any already- + // built artist rows once the fresh set arrives (walk > rebuild_pinned: no + // model swap, no artwork-job re-dispatch, no flicker). + { + let runtime = runtime.clone(); + let weak = weak.clone(); + tokio::spawn(async move { + if crate::offline_mode::engine().is_offline() { + return; + } + match runtime.core().favorite_artist_ids().await { + Ok(ids) => { + let _ = + tokio::task::spawn_blocking(move || fav_cache::set_all_artists(ids)).await; + let _ = weak.upgrade_in_event_loop(move |w| { + let pm = w.global::().get_items(); + for i in 0..pm.row_count() { + if let Some(mut it) = pm.row_data(i) { + if it.kind == "artist" { + let following = it + .artist + .id + .parse::() + .map(|id| fav_cache::is_artist_favorite(id)) + .unwrap_or(false); + if it.artist.following != following { + it.artist.following = following; + pm.set_row_data(i, it); + } + } + } + } + }); + } + Err(e) => log::warn!("[qbz-slint] favorite artists warm failed: {e}"), + } + }); + } + // Load Audio + Playback settings into the Settings page in the // background — store reads and device enumeration are blocking. spawn_settings_snapshot_load(runtime.clone(), weak.clone(), settings_ctx.clone());