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:
2 files changed
+126
-8
No files matched your search
@@ -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
|
||||
/// 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<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user