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
This commit is contained in:
vicrodh committed 2026-06-10 02:00:55 -06:00
1 parent 3d27b1feb4
commit 7d5f6658df
2 files changed
+126 -8

No files matched your search

+107 -2
View File
@@ -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<ResolvedItem> {
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<qbz_offline_cache::CachedTrackInfo> = 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::<u64>().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 /// resolveItems pass (spec §17): resolve every rendered row's tracks via the
/// shared enqueue resolver (`myqbz_play::fetch_item_tracks` — the SAME /// shared enqueue resolver (`myqbz_play::fetch_item_tracks` — the SAME
/// qobuz/local/plex backends), derive the row's source kind + album-level /// 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 /// `apply` (alongside the artwork jobs); already-cached rows are skipped, so a
/// re-derive is instant. Fire-and-forget: failures leave the stored-source /// re-derive is instant. Fire-and-forget: failures leave the stored-source
/// defaults in place. /// 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( pub fn resolve_items(
runtime: std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>, runtime: std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
weak: slint::Weak<AppWindow>, weak: slint::Weak<AppWindow>,
@@ -893,8 +984,22 @@ pub fn resolve_items(
let weak = weak.clone(); let weak = weak.clone();
let image_cache = image_cache.clone(); let image_cache = image_cache.clone();
handle.spawn(async move { handle.spawn(async move {
let tracks = crate::myqbz_play::fetch_item_tracks(&runtime, &full_item).await; // B10 — OFFLINE: cached Qobuz items resolve their badges locally
let resolved = resolve_from_tracks(&full_item, &tracks); // (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 = source_str(full_item.source).to_string();
let source_item_id = full_item.source_item_id.clone(); let source_item_id = full_item.source_item_id.clone();
let stored_artwork_empty = full_item let stored_artwork_empty = full_item
+19 -6
View File
@@ -205,12 +205,25 @@ pub(crate) async fn fetch_item_tracks(
match resolver.resolve(item).await { match resolver.resolve(item).await {
Ok(tracks) => tracks, Ok(tracks) => tracks,
Err(e) => { Err(e) => {
log::warn!( // B10 — while OFFLINE the Qobuz arms are gate-refused by design
"[qbz-slint] myqbz_play: fetch_item_tracks {}/{} failed: {}", // (cached items derive their badges locally in myqbz_detail), so a
item.source_item_id, // per-item failure here is expected noise, not a fault — log it at
item.title, // debug. Online failures stay warn.
e 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() Vec::new()
} }
} }