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:
4 files changed
+74
No files matched your search
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user