From 7d5f6658dfd003b87d7d801549e664bdaef4be5b Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:00:55 -0600 Subject: [PATCH] feat(slint): honest MyQBZ quality badges for offline-cached items While offline, cached items resolve their badges from the offline-cache index (stored tier, bit depth, sample rate via the shared quality formatter - the same rule the LocalLibrary offline-source rows use) instead of hitting the gate-refused Qobuz resolve and falling back to empty defaults. Release-type classifies over the stored track count with the READY count as fallback. Online resolution is byte-identical (single is_offline gate); the per-item fetch warn drops to debug only offline. cc --- crates/qbz-slint/src/myqbz_detail.rs | 109 ++++++++++++++++++++++++++- crates/qbz-slint/src/myqbz_play.rs | 25 ++++-- 2 files changed, 126 insertions(+), 8 deletions(-) diff --git a/crates/qbz-slint/src/myqbz_detail.rs b/crates/qbz-slint/src/myqbz_detail.rs index c7a7ce930..d055d3b0b 100644 --- a/crates/qbz-slint/src/myqbz_detail.rs +++ b/crates/qbz-slint/src/myqbz_detail.rs @@ -856,6 +856,92 @@ fn resolve_from_tracks( } } +/// B10 — OFFLINE: derive a cached Qobuz item's badge values from LOCAL sources +/// instead of the Qobuz API (which the offline gate refuses, leaving the badge +/// empty). The offline index (`index.db` `cached_tracks` rows, surfaced as +/// `CachedTrackInfo`) carries `quality` / `bit_depth` / `sample_rate` per +/// track; the tier/detail rule is the SAME one `resolve_from_tracks` and the +/// LocalLibrary "Offline"-source rows use (24-bit+ = Hi-Res, detail via +/// `crate::quality::detail` — the library.db `qobuz_download` rows derive their +/// badge from these very columns at sync time), so the offline badge matches +/// every other surface. Returns `None` for non-Qobuz items, Qobuz playlists +/// (membership is API-side), and uncached/non-ready items — the caller then +/// falls through to the normal resolver path. +async fn resolve_offline_cached(item: &MixtapeCollectionItem) -> Option { + use qbz_offline_cache::OfflineCacheStatus; + + if item.source != AlbumSource::Qobuz { + return None; + } + let off = crate::offline::get().await?; + let guard = off.db.lock().await; + let db = guard.as_ref()?; + + let (info, ready_count) = match item.item_type { + ItemType::Album => { + let ready: Vec = db + .get_album_tracks(&item.source_item_id) + .ok()? + .into_iter() + .filter(|cached| matches!(cached.status, OfflineCacheStatus::Ready)) + .collect(); + let count = ready.len(); + (ready.into_iter().next()?, count) + } + ItemType::Track => { + let id = item.source_item_id.parse::().ok()?; + let info = db.get_track(id).ok().flatten()?; + if !matches!(info.status, OfflineCacheStatus::Ready) { + return None; + } + (info, 1) + } + // Qobuz playlist membership lives in the API — not derivable locally. + ItemType::Playlist => return None, + }; + + // Quality tier/detail — the resolve_from_tracks rule over the index + // columns. The stored quality string ("UltraHiRes" etc.) stands in for the + // missing hires flag when bit_depth is NULL. + let quality_tier = match info.bit_depth { + Some(d) if d >= 24 => "hires", + Some(_) => "cd", + None if info.quality.to_ascii_lowercase().contains("hires") => "hires", + None => "", + }; + let quality_detail = if quality_tier.is_empty() { + String::new() + } else { + crate::quality::detail(info.bit_depth, info.sample_rate) + }; + + // Type label: albums classify by the stored track_count (the album's REAL + // count, captured at add time); the cached READY count is the fallback for + // older rows saved without one (a floor — partial caches undercount). + let type_label = match item.item_type { + ItemType::Album => { + let count = item + .track_count + .filter(|n| *n > 0) + .map(|n| n as u32) + .unwrap_or(ready_count as u32); + crate::album_map::classify_release_type(Some(count)).to_uppercase() + } + other => type_label(other).to_string(), + }; + + Some(ResolvedItem { + source_kind: "qobuz".to_string(), + quality_tier: quality_tier.to_string(), + quality_detail, + type_label, + // No artwork backfill offline: the empty-stored-art backfill is a + // local-item concern (those still resolve through the normal path), + // and a Qobuz CDN url could not be fetched offline anyway. + artwork_url: String::new(), + }) +} + /// resolveItems pass (spec §17): resolve every rendered row's tracks via the /// shared enqueue resolver (`myqbz_play::fetch_item_tracks` — the SAME /// qobuz/local/plex backends), derive the row's source kind + album-level @@ -865,6 +951,11 @@ fn resolve_from_tracks( /// `apply` (alongside the artwork jobs); already-cached rows are skipped, so a /// re-derive is instant. Fire-and-forget: failures leave the stored-source /// defaults in place. +/// +/// OFFLINE (B10): a cached Qobuz item's badges resolve from the LOCAL offline +/// index via `resolve_offline_cached` — the API path would be gate-refused and +/// the badge would stay empty. Online the branch is never taken, so that path +/// is byte-identical. pub fn resolve_items( runtime: std::sync::Arc>, weak: slint::Weak, @@ -893,8 +984,22 @@ pub fn resolve_items( let weak = weak.clone(); let image_cache = image_cache.clone(); handle.spawn(async move { - let tracks = crate::myqbz_play::fetch_item_tracks(&runtime, &full_item).await; - let resolved = resolve_from_tracks(&full_item, &tracks); + // B10 — OFFLINE: cached Qobuz items resolve their badges locally + // (offline index); everything else (online, local/plex, uncached) + // takes the existing resolver path unchanged. + let offline_resolved = if crate::offline_mode::engine().is_offline() { + resolve_offline_cached(&full_item).await + } else { + None + }; + let resolved = match offline_resolved { + Some(r) => r, + None => { + let tracks = + crate::myqbz_play::fetch_item_tracks(&runtime, &full_item).await; + resolve_from_tracks(&full_item, &tracks) + } + }; let source = source_str(full_item.source).to_string(); let source_item_id = full_item.source_item_id.clone(); let stored_artwork_empty = full_item diff --git a/crates/qbz-slint/src/myqbz_play.rs b/crates/qbz-slint/src/myqbz_play.rs index c2f409dd1..c55546840 100644 --- a/crates/qbz-slint/src/myqbz_play.rs +++ b/crates/qbz-slint/src/myqbz_play.rs @@ -205,12 +205,25 @@ pub(crate) async fn fetch_item_tracks( match resolver.resolve(item).await { Ok(tracks) => tracks, Err(e) => { - log::warn!( - "[qbz-slint] myqbz_play: fetch_item_tracks {}/{} failed: {}", - item.source_item_id, - item.title, - e - ); + // B10 — while OFFLINE the Qobuz arms are gate-refused by design + // (cached items derive their badges locally in myqbz_detail), so a + // per-item failure here is expected noise, not a fault — log it at + // debug. Online failures stay warn. + if crate::offline_mode::engine().is_offline() { + log::debug!( + "[qbz-slint] myqbz_play: fetch_item_tracks {}/{} failed offline: {}", + item.source_item_id, + item.title, + e + ); + } else { + log::warn!( + "[qbz-slint] myqbz_play: fetch_item_tracks {}/{} failed: {}", + item.source_item_id, + item.title, + e + ); + } Vec::new() } }