feat(core): shared offline-tier resolver (Slice 1 foundation)

Adds qbz_core::offline_resolve::resolve_offline_bytes — the OFFLINE tier of
the playback tier-walk, frontend-agnostic and player-untouched (the user's
locked Slice-1 decision): resolve a track's offline-cached bytes by Qobuz id
(v2 CMAF decrypt via the secret vault, optionally emitting UnlockStart/End
through a sink; v1 plain-FLAC read; None on miss).

qbz-core now depends on qbz-offline-cache (aligns with the long-intended
`offline` feature seam). Both frontends will slot this in before the network
tier: Tauri keeps its hardware/quality gate around the returned bytes; Slint
calls it before Player::play_track. Player's L1/L2 + network path is unchanged.

Next: QbzCore compose methods (play_track_resolved / gapless), wire Slint's
session to construct + own an OfflineCacheState, route the Slint play path
through the resolver, and point Tauri's offline branch at it.

cc
This commit is contained in:
vicrodh committed 2026-05-27 20:53:26 -06:00
1 parent c8158bf419
commit d0a4a3299b
4 files changed
+74

No files matched your search

+1
View File
@@ -5058,6 +5058,7 @@ dependencies = [
"qbz-audio", "qbz-audio",
"qbz-integrations", "qbz-integrations",
"qbz-models", "qbz-models",
"qbz-offline-cache",
"qbz-player", "qbz-player",
"qbz-qobuz", "qbz-qobuz",
"qbz-radio", "qbz-radio",
+1
View File
@@ -12,6 +12,7 @@ qbz-player = { path = "../qbz-player" }
qbz-qobuz = { path = "../qbz-qobuz" } qbz-qobuz = { path = "../qbz-qobuz" }
qbz-radio = { path = "../qbz-radio" } qbz-radio = { path = "../qbz-radio" }
qbz-integrations = { path = "../qbz-integrations" } qbz-integrations = { path = "../qbz-integrations" }
qbz-offline-cache = { path = "../qbz-offline-cache" }
# Async # Async
tokio = { workspace = true } tokio = { workspace = true }
+1
View File
@@ -54,6 +54,7 @@
pub mod core; pub mod core;
pub mod error; pub mod error;
pub mod offline_resolve;
pub mod system_capabilities; pub mod system_capabilities;
// Re-exports from qbz-models for convenience // Re-exports from qbz-models for convenience
+71
View File
@@ -0,0 +1,71 @@
//! Shared offline-cache → bytes resolver (frontend-agnostic).
//!
//! This is the OFFLINE tier of the playback tier-walk. Both frontends slot
//! it in BEFORE the network tier in their play path; `qbz-player` (which
//! already owns L1/L2 + the network/CMAF pipeline) stays untouched.
//!
//! - Tauri's `v2_play_track` / `v2_play_next_gapless` resolve offline bytes
//! through this helper (keeping their hardware-compatibility / quality
//! gating around the returned bytes).
//! - Slint's play path calls it before `Player::play_track`.
use std::path::Path;
use qbz_offline_cache::{CacheEventSink, OfflineCacheState};
/// Resolve a track's offline-cached bytes by its **Qobuz track id**.
///
/// - `cache_format = 2` (CMAF) → read the bundle, unwrap the content key via
/// the secret vault, and decrypt to a complete FLAC (off the async thread).
/// When `sink` is `Some`, `UnlockStart`/`UnlockEnd` are emitted around the
/// decrypt so a frontend can show an "unlocking" hint on the row.
/// - `cache_format = 1` (legacy plain FLAC) → read the file directly.
/// - no ready row / any failure → `None` (the caller treats it as a cache
/// miss and continues to the next tier / the network).
///
/// Offline copies are always downloaded at the top quality tier, so there is
/// no quality gate here; a frontend that must honor a hardware limit (e.g.
/// the Tauri ALSA path) applies its own check to the returned bytes.
pub async fn resolve_offline_bytes(
track_id: u64,
offline: &OfflineCacheState,
sink: Option<&CacheEventSink>,
) -> Option<Vec<u8>> {
let row = {
let guard = offline.db.lock().await;
guard.as_ref()?.get_cmaf_bundle(track_id).ok().flatten()?
};
match row.cache_format {
2 => {
let cache_path = offline.get_cache_path();
match sink {
Some(sink) => {
qbz_offline_cache::load_cmaf_bundle_with_ui_events(
sink, track_id, track_id, row, cache_path,
)
.await
}
None => tokio::task::spawn_blocking(move || {
qbz_offline_cache::load_cmaf_bundle(track_id, &row, Path::new(&cache_path))
})
.await
.ok()
.flatten(),
}
}
_ => {
let path = Path::new(&row.segments_path);
if path.exists() {
std::fs::read(path).ok()
} else {
log::warn!(
"[OfflineResolve] track {} v1 row points to a missing file {:?}",
track_id,
path
);
None
}
}
}
}