feat(offline-cache): scaffold qbz-offline-cache crate with the tauri-free core

Slice 0 of the offline-cache port (extraction per ADR-006). Creates the new
qbz-offline-cache workspace crate and moves the tauri-free leaf modules out of
src-tauri/src/offline_cache/ verbatim:

- types: the shared serde DTOs (was inline in offline_cache/mod.rs)
- db: the SQLite cached_tracks index (super:: -> crate::types)
- secret_vault, cmaf_store, path_validator: moved verbatim
- maintenance: super:: refs re-pointed to crate::

src-tauri is untouched and keeps its own copy, so the production build stays
green; the re-point + delete happens at the end of Slice 0. Crate compiles
in isolation and all 5 unit tests pass.

Remaining for Slice 0: playback pure-fn split, metadata/migration/purge
(re-point crate::api/crate::library -> qbz_qobuz/qbz_library), the downloader
+ CacheEvent/CacheEventSink abstraction, the OfflineCacheState struct, then
re-wire the Tauri commands and delete the old module.

cc
This commit is contained in:
vicrodh committed 2026-05-27 19:08:52 -06:00
1 parent 81e7d21868
commit d6e9345f5a
10 files changed
+1577

No files matched your search

+14
View File
@@ -5129,6 +5129,20 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "qbz-offline-cache"
version = "0.1.0"
dependencies = [
"log",
"qbz-cmaf",
"qbz-qobuz",
"qbz-secrets",
"rusqlite",
"serde",
"serde_json",
"tempfile",
]
[[package]]
name = "qbz-player"
version = "0.1.0"
+1
View File
@@ -15,6 +15,7 @@ members = [
"qbz-cast",
"qbz-cmaf",
"qbz-secrets",
"qbz-offline-cache",
"qconnect-core",
"qconnect-protocol",
"qconnect-transport-ws",
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "qbz-offline-cache"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Offline cache for QBZ: download-for-offline store, CMAF bundle vault, SQLite index, cached playback resolution (frontend-agnostic)"
[dependencies]
# Sibling crates: the network + crypto + init-parse already live here.
qbz-qobuz = { path = "../qbz-qobuz" }
qbz-cmaf = { path = "../qbz-cmaf" }
qbz-secrets = { path = "../qbz-secrets" }
# SQLite index (match qbz-library's version/features)
rusqlite = { version = "0.31", features = ["bundled"] }
# DTO serialization (camelCase envelopes shared with the frontend)
serde = { workspace = true }
serde_json = { workspace = true }
# Logging
log = { workspace = true }
[dev-dependencies]
tempfile = "3"
+238
View File
@@ -0,0 +1,238 @@
//! On-disk layout and I/O for v2 CMAF-bundle offline cache entries.
//!
//! Layout under `<offline_root>/tracks-cmaf/<track_id>/`:
//!
//! ```text
//! init.mp4 — init segment (unencrypted container + FLAC header)
//! segments.bin — concatenated encrypted audio segments (s=1..=n)
//! manifest.json — small recovery manifest (segment offsets + n_segments)
//! ```
//!
//! The `manifest.json` is a belt-and-suspenders convenience: SQLite is the
//! authoritative source of truth, but if the DB is ever lost we can still
//! tell the caller how to slice `segments.bin` back into per-segment
//! buffers from the manifest. It's cheap to write and cheap to read.
//!
//! Everything here is intentionally I/O-only — no network, no crypto. The
//! CMAF download itself happens in qbz-qobuz; this module just persists
//! the bytes in a format the playback path can read back efficiently.
use std::fs;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use qbz_qobuz::cmaf::CmafRawBundle;
use serde::{Deserialize, Serialize};
const SUBDIR: &str = "tracks-cmaf";
const INIT_FILENAME: &str = "init.mp4";
const SEGMENTS_FILENAME: &str = "segments.bin";
const MANIFEST_FILENAME: &str = "manifest.json";
/// Lightweight sidecar manifest saved next to the bundle. If the SQLite
/// index is ever lost or an integrity check fails, this is enough to
/// reconstruct the per-segment slicing so the decrypt path can still
/// iterate the concatenated `segments.bin`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleManifest {
pub version: u8,
pub track_id: u64,
pub format_id: u32,
pub n_segments: u32,
/// Offset in bytes of each encrypted segment inside `segments.bin`.
/// `segment_offsets[i]` = start of segment `i+1`; `segment_offsets[n]`
/// is the total size. Length = `n_segments + 1`.
pub segment_offsets: Vec<u64>,
}
/// Where the v2 bundle for a given track id lives on disk. Callers use
/// this to build DB rows and to locate existing bundles at playback.
#[derive(Debug, Clone)]
pub struct BundleLayout {
pub track_dir: PathBuf,
pub init_path: PathBuf,
pub segments_path: PathBuf,
pub manifest_path: PathBuf,
}
impl BundleLayout {
pub fn new(offline_root: &Path, track_id: u64) -> Self {
let track_dir = offline_root.join(SUBDIR).join(track_id.to_string());
Self {
init_path: track_dir.join(INIT_FILENAME),
segments_path: track_dir.join(SEGMENTS_FILENAME),
manifest_path: track_dir.join(MANIFEST_FILENAME),
track_dir,
}
}
}
/// Writes a freshly-downloaded [`CmafRawBundle`] to disk under the track
/// directory, returning the layout + total size of the persisted bytes.
///
/// Note: this does NOT write any key material. The caller is responsible
/// for wrapping `bundle.content_key` / `bundle.infos` via `qbz-secrets`
/// and persisting those blobs to the SQLite row.
pub fn persist_bundle(
offline_root: &Path,
track_id: u64,
bundle: &CmafRawBundle,
) -> Result<(BundleLayout, u64), String> {
let layout = BundleLayout::new(offline_root, track_id);
fs::create_dir_all(&layout.track_dir)
.map_err(|e| format!("Failed to create bundle dir {:?}: {}", layout.track_dir, e))?;
// Init segment (unencrypted, small)
write_atomic(&layout.init_path, &bundle.init_bytes)
.map_err(|e| format!("Failed to write init: {}", e))?;
// Audio segments: concatenated into a single file, with offsets tracked
// for the manifest so playback can slice them back apart.
let segments_tmp = layout.segments_path.with_extension("tmp");
let file = fs::File::create(&segments_tmp)
.map_err(|e| format!("Failed to create segments file: {}", e))?;
let mut writer = BufWriter::new(file);
let mut offsets: Vec<u64> = Vec::with_capacity(bundle.segments.len() + 1);
let mut cursor: u64 = 0;
offsets.push(cursor);
for seg in &bundle.segments {
writer
.write_all(seg)
.map_err(|e| format!("Failed to write segment: {}", e))?;
cursor += seg.len() as u64;
offsets.push(cursor);
}
writer
.flush()
.map_err(|e| format!("Failed to flush segments: {}", e))?;
let file = writer
.into_inner()
.map_err(|e| format!("Failed to finalize writer: {}", e))?;
file.sync_all()
.map_err(|e| format!("Failed to fsync segments: {}", e))?;
drop(file);
fs::rename(&segments_tmp, &layout.segments_path)
.map_err(|e| format!("Failed to rename segments: {}", e))?;
let manifest = BundleManifest {
version: 1,
track_id,
format_id: bundle.format_id,
n_segments: bundle.n_segments as u32,
segment_offsets: offsets.clone(),
};
let manifest_json = serde_json::to_vec_pretty(&manifest)
.map_err(|e| format!("Failed to serialize manifest: {}", e))?;
write_atomic(&layout.manifest_path, &manifest_json)
.map_err(|e| format!("Failed to write manifest: {}", e))?;
let total_bytes = bundle.init_bytes.len() as u64 + cursor + manifest_json.len() as u64;
log::info!(
"[OfflineCache/CMAF] Persisted bundle for track {}: init={}B, segments={}B ({} files), manifest={}B, total={:.2} MB",
track_id,
bundle.init_bytes.len(),
cursor,
bundle.segments.len(),
manifest_json.len(),
total_bytes as f64 / (1024.0 * 1024.0),
);
Ok((layout, total_bytes))
}
/// Load a bundle back from disk for playback. Returns the init bytes and
/// the per-segment slices of `segments.bin` in order.
pub fn read_bundle(layout: &BundleLayout) -> Result<LoadedBundle, String> {
let init_bytes = fs::read(&layout.init_path)
.map_err(|e| format!("Failed to read init {:?}: {}", layout.init_path, e))?;
let segments_blob = fs::read(&layout.segments_path)
.map_err(|e| format!("Failed to read segments {:?}: {}", layout.segments_path, e))?;
let manifest_bytes = fs::read(&layout.manifest_path)
.map_err(|e| format!("Failed to read manifest {:?}: {}", layout.manifest_path, e))?;
let manifest: BundleManifest = serde_json::from_slice(&manifest_bytes)
.map_err(|e| format!("Failed to parse manifest: {}", e))?;
let n = manifest.n_segments as usize;
if manifest.segment_offsets.len() != n + 1 {
return Err(format!(
"Manifest offsets length {} doesn't match n_segments {}+1",
manifest.segment_offsets.len(),
n
));
}
let mut segments: Vec<Vec<u8>> = Vec::with_capacity(n);
for i in 0..n {
let start = manifest.segment_offsets[i] as usize;
let end = manifest.segment_offsets[i + 1] as usize;
if end > segments_blob.len() {
return Err(format!(
"Segment {} offset {}..{} past blob size {}",
i + 1,
start,
end,
segments_blob.len()
));
}
segments.push(segments_blob[start..end].to_vec());
}
Ok(LoadedBundle {
init_bytes,
segments,
manifest,
})
}
/// Remove a bundle from disk (called by eviction / re-download).
pub fn remove_bundle(layout: &BundleLayout) {
if layout.track_dir.exists() {
if let Err(e) = fs::remove_dir_all(&layout.track_dir) {
log::warn!(
"[OfflineCache/CMAF] Failed to remove bundle dir {:?}: {}",
layout.track_dir,
e
);
}
}
}
/// A freshly-loaded bundle ready to be decrypted + played. Owned buffers
/// so the caller can feed the player without holding any file locks.
pub struct LoadedBundle {
pub init_bytes: Vec<u8>,
pub segments: Vec<Vec<u8>>,
pub manifest: BundleManifest,
}
impl LoadedBundle {
/// Decrypt the bundle into a complete, playable FLAC byte stream.
///
/// The layout is `flac_header || decrypted_frames` — identical to
/// what the streaming playback path produces, so the player can
/// consume it via `play_data` exactly like a cached plain FLAC.
pub fn decrypt_to_flac(&self, content_key: &[u8; 16]) -> Result<Vec<u8>, String> {
let init_info = qbz_cmaf::parse_init_segment(&self.init_bytes)
.map_err(|e| format!("Failed to parse init segment: {}", e))?;
let total = init_info.flac_header.len()
+ init_info
.segment_table
.iter()
.map(|s| s.byte_len as usize)
.sum::<usize>();
let mut out = Vec::with_capacity(total);
out.extend_from_slice(&init_info.flac_header);
qbz_qobuz::cmaf::decrypt_segments_into(&self.segments, content_key, &mut out)?;
Ok(out)
}
}
fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension("tmp");
{
let mut f = fs::File::create(&tmp)?;
f.write_all(data)?;
f.sync_all()?;
}
fs::rename(&tmp, path)?;
Ok(())
}
+750
View File
@@ -0,0 +1,750 @@
//! SQLite database for offline cache index
use rusqlite::{params, Connection};
use std::path::Path;
use crate::types::{
CachedTrackInfo, OfflineCacheStats, OfflineCacheStatus, ReadyTrackForSync, TrackCacheInfo,
};
/// Maps a `cached_tracks` row (with the canonical 15-column SELECT used by
/// `get_track`, `get_all_tracks`, and `get_album_tracks`) into a `CachedTrackInfo`.
///
/// SELECT must be:
/// `track_id, title, artist, album, album_id, duration_secs, file_size_bytes,
/// quality, bit_depth, sample_rate, status, progress_percent, error_message,
/// created_at, last_accessed_at`
fn row_to_cached_track_info(row: &rusqlite::Row) -> rusqlite::Result<CachedTrackInfo> {
Ok(CachedTrackInfo {
track_id: row.get::<_, i64>(0)? as u64,
title: row.get(1)?,
artist: row.get(2)?,
album: row.get(3)?,
album_id: row.get(4)?,
duration_secs: row.get::<_, i64>(5)? as u64,
file_size_bytes: row.get::<_, i64>(6)? as u64,
quality: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
bit_depth: row.get::<_, Option<i64>>(8)?.map(|v| v as u32),
sample_rate: row.get(9)?,
status: OfflineCacheStatus::from_str(&row.get::<_, String>(10)?),
progress_percent: row.get::<_, i64>(11)? as u8,
error_message: row.get(12)?,
created_at: row.get(13)?,
last_accessed_at: row.get(14)?,
})
}
/// Database wrapper for cached tracks index
pub struct OfflineCacheDb {
conn: Connection,
}
impl OfflineCacheDb {
/// Open or create the database
pub fn new(path: &Path) -> Result<Self, String> {
let conn = Connection::open(path)
.map_err(|e| format!("Failed to open offline cache database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for offline cache database: {}", e))?;
let db = Self { conn };
db.init_schema()?;
Ok(db)
}
/// Get reference to the connection (for direct queries)
pub fn conn(&self) -> &Connection {
&self.conn
}
/// Initialize database schema
fn init_schema(&self) -> Result<(), String> {
self.conn
.execute_batch(
"
CREATE TABLE IF NOT EXISTS cached_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_id INTEGER UNIQUE NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
album_id TEXT,
duration_secs INTEGER NOT NULL,
file_path TEXT NOT NULL,
file_size_bytes INTEGER NOT NULL DEFAULT 0,
format TEXT NOT NULL DEFAULT 'flac',
quality TEXT,
bit_depth INTEGER,
sample_rate REAL,
artwork_path TEXT,
status TEXT NOT NULL DEFAULT 'queued',
progress_percent INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_accessed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_track_id ON cached_tracks(track_id);
CREATE INDEX IF NOT EXISTS idx_status ON cached_tracks(status);
CREATE INDEX IF NOT EXISTS idx_last_accessed ON cached_tracks(last_accessed_at);
",
)
.map_err(|e| format!("Failed to initialize database schema: {}", e))?;
self.migrate_v2_cmaf_columns()?;
Ok(())
}
/// Additive migration for the v2 offline format.
///
/// Adds columns for bit-identical CMAF storage:
/// - `cache_format`: 1 = legacy plain FLAC, 2 = raw CMAF bundle
/// - `init_path`: path to the init.mp4 (contains FLAC header + table)
/// - `content_key_wrapped`: AES content key wrapped with qbz-secrets
/// - `infos_wrapped`: session infos salt wrapped with qbz-secrets
/// - `format_id`: Qobuz format id (e.g. 5/6/7/27)
/// - `n_segments`: number of audio segments (s=1..=n)
///
/// Existing rows keep `cache_format=1` so playback continues to read
/// the legacy plain-FLAC `file_path` for them. New downloads go to
/// `cache_format=2`. We never rewrite v1 rows into v2 — the two
/// formats coexist until the v1 rows naturally expire via the
/// subscription-lapse cache wipe or user-triggered re-download.
fn migrate_v2_cmaf_columns(&self) -> Result<(), String> {
let existing = self.existing_columns("cached_tracks")?;
let add = |col: &str, ddl: &str| -> Result<(), String> {
if !existing.iter().any(|c| c == col) {
let sql = format!("ALTER TABLE cached_tracks ADD COLUMN {}", ddl);
self.conn
.execute(&sql, [])
.map_err(|e| format!("Failed to add column {}: {}", col, e))?;
log::info!(
"[OfflineCache/MIGRATE] Added column {} to cached_tracks",
col
);
}
Ok(())
};
add("cache_format", "cache_format INTEGER NOT NULL DEFAULT 1")?;
add("init_path", "init_path TEXT")?;
add("content_key_wrapped", "content_key_wrapped BLOB")?;
add("infos_wrapped", "infos_wrapped BLOB")?;
add("format_id", "format_id INTEGER")?;
add("n_segments", "n_segments INTEGER")?;
Ok(())
}
fn existing_columns(&self, table: &str) -> Result<Vec<String>, String> {
let mut stmt = self
.conn
.prepare(&format!("PRAGMA table_info({})", table))
.map_err(|e| format!("Failed to prepare PRAGMA: {}", e))?;
let cols = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| format!("Failed to read PRAGMA: {}", e))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Failed to iterate PRAGMA: {}", e))?;
Ok(cols)
}
/// Insert a new track to cache for offline
pub fn insert_track(&self, info: &TrackCacheInfo, file_path: &str) -> Result<(), String> {
self.conn.execute(
"INSERT OR REPLACE INTO cached_tracks
(track_id, title, artist, album, album_id, duration_secs, file_path, quality, bit_depth, sample_rate, status, progress_percent, created_at, last_accessed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'queued', 0, datetime('now'), datetime('now'))",
params![
info.track_id as i64,
info.title,
info.artist,
info.album,
info.album_id,
info.duration_secs as i64,
file_path,
info.quality,
info.bit_depth.map(|v| v as i64),
info.sample_rate,
],
).map_err(|e| format!("Failed to insert track: {}", e))?;
Ok(())
}
/// Insert multiple tracks in a single transaction (batch queuing)
pub fn insert_tracks_batch(&self, tracks: &[(&TrackCacheInfo, String)]) -> Result<(), String> {
let tx = self
.conn
.unchecked_transaction()
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
{
let mut stmt = tx.prepare(
"INSERT OR REPLACE INTO cached_tracks
(track_id, title, artist, album, album_id, duration_secs, file_path, quality, bit_depth, sample_rate, status, progress_percent, created_at, last_accessed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'queued', 0, datetime('now'), datetime('now'))"
).map_err(|e| format!("Failed to prepare batch insert: {}", e))?;
for (info, file_path) in tracks {
stmt.execute(params![
info.track_id as i64,
info.title,
info.artist,
info.album,
info.album_id,
info.duration_secs as i64,
file_path,
info.quality,
info.bit_depth.map(|v| v as i64),
info.sample_rate,
])
.map_err(|e| format!("Failed to insert track {}: {}", info.track_id, e))?;
}
}
tx.commit()
.map_err(|e| format!("Failed to commit batch insert: {}", e))?;
Ok(())
}
/// Update track status
pub fn update_status(
&self,
track_id: u64,
status: OfflineCacheStatus,
error: Option<&str>,
) -> Result<(), String> {
self.conn
.execute(
"UPDATE cached_tracks SET status = ?1, error_message = ?2 WHERE track_id = ?3",
params![status.as_str(), error, track_id as i64],
)
.map_err(|e| format!("Failed to update status: {}", e))?;
Ok(())
}
/// Update caching progress
pub fn update_progress(
&self,
track_id: u64,
progress: u8,
size_bytes: u64,
) -> Result<(), String> {
self.conn.execute(
"UPDATE cached_tracks SET progress_percent = ?1, file_size_bytes = ?2 WHERE track_id = ?3",
params![progress as i64, size_bytes as i64, track_id as i64],
).map_err(|e| format!("Failed to update progress: {}", e))?;
Ok(())
}
/// Mark caching as complete
pub fn mark_complete(&self, track_id: u64, file_size: u64) -> Result<(), String> {
self.conn.execute(
"UPDATE cached_tracks SET status = 'ready', progress_percent = 100, file_size_bytes = ?1, last_accessed_at = datetime('now') WHERE track_id = ?2",
params![file_size as i64, track_id as i64],
).map_err(|e| format!("Failed to mark complete: {}", e))?;
Ok(())
}
/// Update last accessed time (for LRU)
pub fn touch(&self, track_id: u64) -> Result<(), String> {
self.conn
.execute(
"UPDATE cached_tracks SET last_accessed_at = datetime('now') WHERE track_id = ?1",
params![track_id as i64],
)
.map_err(|e| format!("Failed to update access time: {}", e))?;
Ok(())
}
/// Check if a track is cached and ready
pub fn is_cached(&self, track_id: u64) -> Result<bool, String> {
let count: i64 = self
.conn
.query_row(
"SELECT COUNT(*) FROM cached_tracks WHERE track_id = ?1 AND status = 'ready'",
params![track_id as i64],
|row| row.get(0),
)
.map_err(|e| format!("Failed to check cache: {}", e))?;
Ok(count > 0)
}
/// Get file path for a cached track
pub fn get_file_path(&self, track_id: u64) -> Result<Option<String>, String> {
let result = self.conn.query_row(
"SELECT file_path FROM cached_tracks WHERE track_id = ?1 AND status = 'ready'",
params![track_id as i64],
|row| row.get(0),
);
match result {
Ok(path) => Ok(Some(path)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("Failed to get file path: {}", e)),
}
}
/// Get all ready (cached) tracks with their file paths for syncing to library
pub fn get_ready_tracks_for_sync(&self) -> Result<Vec<ReadyTrackForSync>, String> {
let mut stmt = self.conn.prepare(
"SELECT track_id, title, artist, album, duration_secs, file_path, bit_depth, sample_rate
FROM cached_tracks WHERE status = 'ready'"
).map_err(|e| format!("Failed to prepare query: {}", e))?;
let tracks = stmt
.query_map([], |row| {
Ok(ReadyTrackForSync {
track_id: row.get::<_, i64>(0)? as u64,
title: row.get(1)?,
artist: row.get(2)?,
album: row.get(3)?,
duration_secs: row.get::<_, i64>(4)? as u64,
file_path: row.get(5)?,
bit_depth: row.get::<_, Option<i64>>(6)?.map(|v| v as u32),
sample_rate: row.get(7)?,
})
})
.map_err(|e| format!("Failed to query tracks: {}", e))?;
let mut result = Vec::new();
for track in tracks {
result.push(track.map_err(|e| format!("Failed to read track: {}", e))?);
}
Ok(result)
}
/// Get track info
pub fn get_track(&self, track_id: u64) -> Result<Option<CachedTrackInfo>, String> {
let result = self.conn.query_row(
"SELECT track_id, title, artist, album, album_id, duration_secs, file_size_bytes, quality, bit_depth, sample_rate, status, progress_percent, error_message, created_at, last_accessed_at
FROM cached_tracks WHERE track_id = ?1",
params![track_id as i64],
row_to_cached_track_info,
);
match result {
Ok(info) => Ok(Some(info)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("Failed to get track: {}", e)),
}
}
/// Get all cached tracks
pub fn get_all_tracks(&self) -> Result<Vec<CachedTrackInfo>, String> {
let mut stmt = self.conn.prepare(
"SELECT track_id, title, artist, album, album_id, duration_secs, file_size_bytes, quality, bit_depth, sample_rate, status, progress_percent, error_message, created_at, last_accessed_at
FROM cached_tracks ORDER BY last_accessed_at DESC"
).map_err(|e| format!("Failed to prepare query: {}", e))?;
let tracks = stmt
.query_map([], row_to_cached_track_info)
.map_err(|e| format!("Failed to query tracks: {}", e))?;
let mut result = Vec::new();
for track in tracks {
result.push(track.map_err(|e| format!("Failed to read track: {}", e))?);
}
Ok(result)
}
/// Returns all cached track rows for a given album_id (any status).
pub fn get_album_tracks(&self, album_id: &str) -> Result<Vec<CachedTrackInfo>, String> {
let mut stmt = self
.conn()
.prepare(
"SELECT track_id, title, artist, album, album_id, duration_secs,
file_size_bytes, quality, bit_depth, sample_rate, status,
progress_percent, error_message, created_at, last_accessed_at
FROM cached_tracks WHERE album_id = ?1",
)
.map_err(|e| format!("Prepare failed: {}", e))?;
let rows = stmt
.query_map([album_id], row_to_cached_track_info)
.map_err(|e| format!("Query failed: {}", e))?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| format!("Row decode failed: {}", e))
}
/// Resets a track row to Pending state for re-download.
/// Clears progress_percent and error_message.
pub fn reset_track_for_redownload(&self, track_id: u64) -> Result<(), String> {
self.conn()
.execute(
"UPDATE cached_tracks
SET status = 'queued', progress_percent = 0, error_message = NULL
WHERE track_id = ?1",
[track_id as i64],
)
.map_err(|e| format!("Update failed: {}", e))?;
Ok(())
}
/// Delete a track from cache
pub fn delete_track(&self, track_id: u64) -> Result<Option<String>, String> {
// Get file path before deleting
let file_path: Option<String> = self
.conn
.query_row(
"SELECT file_path FROM cached_tracks WHERE track_id = ?1",
params![track_id as i64],
|row| row.get(0),
)
.ok();
self.conn
.execute(
"DELETE FROM cached_tracks WHERE track_id = ?1",
params![track_id as i64],
)
.map_err(|e| format!("Failed to delete track: {}", e))?;
Ok(file_path)
}
/// Get statistics
pub fn get_stats(
&self,
cache_path: &str,
limit_bytes: Option<u64>,
) -> Result<OfflineCacheStats, String> {
let total_tracks: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM cached_tracks", [], |row| row.get(0))
.map_err(|e| format!("Failed to count tracks: {}", e))?;
let ready_tracks: i64 = self
.conn
.query_row(
"SELECT COUNT(*) FROM cached_tracks WHERE status = 'ready'",
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to count ready tracks: {}", e))?;
let downloading_tracks: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM cached_tracks WHERE status = 'downloading' OR status = 'queued'",
[],
|row| row.get(0),
).map_err(|e| format!("Failed to count downloading tracks: {}", e))?;
let failed_tracks: i64 = self
.conn
.query_row(
"SELECT COUNT(*) FROM cached_tracks WHERE status = 'failed'",
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to count failed tracks: {}", e))?;
let total_size: i64 = self.conn.query_row(
"SELECT COALESCE(SUM(file_size_bytes), 0) FROM cached_tracks WHERE status = 'ready'",
[],
|row| row.get(0),
).map_err(|e| format!("Failed to sum sizes: {}", e))?;
Ok(OfflineCacheStats {
total_tracks: total_tracks as usize,
ready_tracks: ready_tracks as usize,
downloading_tracks: downloading_tracks as usize,
failed_tracks: failed_tracks as usize,
total_size_bytes: total_size as u64,
limit_bytes,
cache_path: cache_path.to_string(),
})
}
/// Get tracks to evict (LRU order) to free up space
pub fn get_tracks_for_eviction(
&self,
bytes_to_free: u64,
) -> Result<Vec<(u64, String)>, String> {
let mut stmt = self
.conn
.prepare(
"SELECT track_id, file_path, file_size_bytes FROM cached_tracks
WHERE status = 'ready'
ORDER BY last_accessed_at ASC",
)
.map_err(|e| format!("Failed to prepare eviction query: {}", e))?;
let mut result = Vec::new();
let mut freed = 0u64;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, i64>(0)? as u64,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)? as u64,
))
})
.map_err(|e| format!("Failed to query for eviction: {}", e))?;
for row in rows {
if freed >= bytes_to_free {
break;
}
let (track_id, file_path, size) =
row.map_err(|e| format!("Failed to read row: {}", e))?;
result.push((track_id, file_path));
freed += size;
}
Ok(result)
}
/// Clear all entries
pub fn clear_all(&self) -> Result<Vec<String>, String> {
// Get all file paths first
let mut stmt = self
.conn
.prepare("SELECT file_path FROM cached_tracks")
.map_err(|e| format!("Failed to prepare query: {}", e))?;
let paths: Vec<String> = stmt
.query_map([], |row| row.get(0))
.map_err(|e| format!("Failed to query paths: {}", e))?
.filter_map(|r| r.ok())
.collect();
self.conn
.execute("DELETE FROM cached_tracks", [])
.map_err(|e| format!("Failed to clear database: {}", e))?;
Ok(paths)
}
/// Update file path for a track (after organizing)
pub fn update_file_path(&self, track_id: u64, new_path: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE cached_tracks SET file_path = ?1 WHERE track_id = ?2",
params![new_path, track_id as i64],
)
.map_err(|e| format!("Failed to update file path: {}", e))?;
Ok(())
}
/// Update artwork path for a track
pub fn update_artwork_path(&self, track_id: u64, artwork_path: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE cached_tracks SET artwork_path = ?1 WHERE track_id = ?2",
params![artwork_path, track_id as i64],
)
.map_err(|e| format!("Failed to update artwork path: {}", e))?;
Ok(())
}
// ==================== v2 CMAF bundle fields ====================
/// Persist the CMAF-specific columns for a track after it was
/// successfully downloaded as a raw encrypted bundle.
///
/// `file_path` here is the concatenated-segments file (or primary
/// segment file, depending on how the caller lays out the bundle on
/// disk). `init_path` is the init.mp4. Both keys are already wrapped
/// by the caller via `qbz-secrets::SecretBox::wrap`.
#[allow(clippy::too_many_arguments)]
pub fn set_cmaf_bundle(
&self,
track_id: u64,
segments_path: &str,
init_path: &str,
content_key_wrapped: &[u8],
infos_wrapped: &[u8],
format_id: u32,
n_segments: u32,
total_bytes: u64,
) -> Result<(), String> {
self.conn
.execute(
"UPDATE cached_tracks
SET cache_format = 2,
file_path = ?1,
init_path = ?2,
content_key_wrapped = ?3,
infos_wrapped = ?4,
format_id = ?5,
n_segments = ?6,
file_size_bytes = ?7
WHERE track_id = ?8",
params![
segments_path,
init_path,
content_key_wrapped,
infos_wrapped,
format_id as i64,
n_segments as i64,
total_bytes as i64,
track_id as i64,
],
)
.map_err(|e| format!("Failed to write CMAF bundle fields: {}", e))?;
Ok(())
}
/// Read back the bundle fields for a track, for offline playback.
pub fn get_cmaf_bundle(&self, track_id: u64) -> Result<Option<CmafBundleRow>, String> {
let mut stmt = self
.conn
.prepare(
"SELECT cache_format, file_path, init_path, content_key_wrapped,
infos_wrapped, format_id, n_segments
FROM cached_tracks
WHERE track_id = ?1",
)
.map_err(|e| format!("Failed to prepare CMAF bundle select: {}", e))?;
let row: Result<CmafBundleRow, _> = stmt.query_row(params![track_id as i64], |row| {
Ok(CmafBundleRow {
cache_format: row.get::<_, i64>(0)? as u8,
segments_path: row.get(1)?,
init_path: row.get::<_, Option<String>>(2)?,
content_key_wrapped: row.get::<_, Option<Vec<u8>>>(3)?,
infos_wrapped: row.get::<_, Option<Vec<u8>>>(4)?,
format_id: row.get::<_, Option<i64>>(5)?.map(|v| v as u32),
n_segments: row.get::<_, Option<i64>>(6)?.map(|v| v as u32),
})
});
match row {
Ok(r) => Ok(Some(r)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("Failed to read CMAF bundle: {}", e)),
}
}
/// Deletes all rows for the given album_id in a single transaction.
/// Returns (deleted track_ids, total file_size_bytes freed).
pub fn delete_album_tracks(&self, album_id: &str) -> Result<(Vec<u64>, u64), String> {
let tx = self
.conn()
.unchecked_transaction()
.map_err(|e| format!("Failed to begin tx: {}", e))?;
let ids: Vec<u64> = {
let mut stmt = tx
.prepare("SELECT track_id FROM cached_tracks WHERE album_id = ?1")
.map_err(|e| format!("Prepare failed: {}", e))?;
let rows = stmt
.query_map([album_id], |row| row.get::<_, i64>(0).map(|v| v as u64))
.map_err(|e| format!("Query failed: {}", e))?;
rows.collect::<rusqlite::Result<Vec<u64>>>()
.map_err(|e| format!("Row decode failed: {}", e))?
};
let bytes: i64 = tx
.query_row(
"SELECT COALESCE(SUM(file_size_bytes), 0) FROM cached_tracks WHERE album_id = ?1",
[album_id],
|row| row.get(0),
)
.map_err(|e| format!("Sum failed: {}", e))?;
tx.execute("DELETE FROM cached_tracks WHERE album_id = ?1", [album_id])
.map_err(|e| format!("Delete failed: {}", e))?;
tx.commit()
.map_err(|e| format!("Commit failed: {}", e))?;
Ok((ids, bytes as u64))
}
}
/// Raw snapshot of the v2 bundle columns for a cached track.
///
/// `cache_format` tells the caller how to interpret the rest:
/// - `1` — legacy plain FLAC at `segments_path`; other fields empty.
/// - `2` — raw CMAF bundle; all fields populated.
#[derive(Debug, Clone)]
pub struct CmafBundleRow {
pub cache_format: u8,
pub segments_path: String,
pub init_path: Option<String>,
pub content_key_wrapped: Option<Vec<u8>>,
pub infos_wrapped: Option<Vec<u8>>,
pub format_id: Option<u32>,
pub n_segments: Option<u32>,
}
#[cfg(test)]
mod maintenance_tests {
use super::*;
use tempfile::TempDir;
fn fresh_db() -> (TempDir, OfflineCacheDb) {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("idx.db");
let db = OfflineCacheDb::new(&path).unwrap();
(tmp, db)
}
fn sample_track(id: u64, album_id: Option<&str>) -> TrackCacheInfo {
TrackCacheInfo {
track_id: id,
title: format!("t{}", id),
artist: "A".into(),
album: Some("Alb".into()),
album_id: album_id.map(String::from),
duration_secs: 100,
quality: "lossless".into(),
bit_depth: Some(16),
sample_rate: Some(44100.0),
}
}
#[test]
fn delete_album_tracks_returns_deleted_ids_and_freed_bytes() {
let (_tmp, db) = fresh_db();
db.insert_track(&sample_track(1, Some("alb1")), "/p/1").unwrap();
db.insert_track(&sample_track(2, Some("alb1")), "/p/2").unwrap();
db.insert_track(&sample_track(3, Some("alb2")), "/p/3").unwrap();
db.mark_complete(1, 1000).unwrap();
db.mark_complete(2, 2000).unwrap();
db.mark_complete(3, 9999).unwrap();
let (ids, bytes) = db.delete_album_tracks("alb1").unwrap();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&1) && ids.contains(&2));
assert_eq!(bytes, 3000);
// alb2 untouched
let remaining = db.get_all_tracks().unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].track_id, 3);
}
#[test]
fn get_album_tracks_returns_only_matching_album() {
let (_tmp, db) = fresh_db();
db.insert_track(&sample_track(1, Some("alb1")), "/p/1").unwrap();
db.insert_track(&sample_track(2, Some("alb2")), "/p/2").unwrap();
let alb1 = db.get_album_tracks("alb1").unwrap();
assert_eq!(alb1.len(), 1);
assert_eq!(alb1[0].track_id, 1);
}
#[test]
fn reset_track_for_redownload_clears_progress_and_error() {
let (_tmp, db) = fresh_db();
db.insert_track(&sample_track(1, Some("alb1")), "/p/1").unwrap();
db.update_status(1, OfflineCacheStatus::Failed, Some("boom")).unwrap();
db.reset_track_for_redownload(1).unwrap();
let track = db.get_track(1).unwrap().unwrap();
assert!(matches!(track.status, OfflineCacheStatus::Queued));
assert_eq!(track.progress_percent, 0);
assert!(track.error_message.is_none());
}
}
+29
View File
@@ -0,0 +1,29 @@
//! Offline cache for QBZ — frontend-agnostic.
//!
//! Downloading Qobuz content for offline playback and managing the local
//! store: the on-disk CMAF bundle store, the per-track secret vault, the
//! SQLite index, maintenance/purge/migration, and the pure cached-playback
//! resolution.
//!
//! Extracted out of `src-tauri/src/offline_cache/` so both the Tauri and
//! the Slint frontends share ONE implementation (ADR-006). The download
//! pipeline emits progress through a `CacheEventSink` callback instead of
//! Tauri events, so this crate has no Tauri dependency.
//!
//! Migration status (Slice 0 — extraction): modules are moved here
//! incrementally; `src-tauri` keeps its own copy until the re-point step,
//! so the workspace stays green throughout.
pub mod cmaf_store;
pub mod db;
pub mod maintenance;
pub mod path_validator;
pub mod secret_vault;
pub mod types;
pub use db::{CmafBundleRow, OfflineCacheDb};
pub use path_validator::{is_offline_root_available, validate_path, PathStatus};
pub use types::{
CacheProgress, CachedTrackInfo, OfflineCacheStats, OfflineCacheStatus, ReadyTrackForSync,
TrackCacheInfo,
};
+142
View File
@@ -0,0 +1,142 @@
//! Maintenance operations on the offline cache: bulk delete and re-download.
//! Pure logic — no Tauri state. Callable from any future TUI or headless binary.
use std::path::Path;
use crate::cmaf_store::BundleLayout;
use crate::{CachedTrackInfo, OfflineCacheDb, OfflineCacheStatus};
#[derive(Debug, Clone)]
pub struct AlbumRemovalReport {
pub album_id: String,
pub removed_track_ids: Vec<u64>,
pub freed_bytes: u64,
}
/// Removes all cached tracks of an album: SQLite rows + on-disk CMAF bundles.
/// Filesystem errors per-track are logged and not propagated; SQLite is the
/// source of truth and the bundle directories are best-effort cleanup.
pub fn remove_album_cached_tracks(
db: &OfflineCacheDb,
offline_root: &Path,
album_id: &str,
) -> Result<AlbumRemovalReport, String> {
let (ids, bytes) = db.delete_album_tracks(album_id)?;
for &track_id in &ids {
let layout = BundleLayout::new(offline_root, track_id);
if layout.track_dir.exists() {
if let Err(e) = std::fs::remove_dir_all(&layout.track_dir) {
log::warn!(
"Failed to remove CMAF dir for track {}: {} (continuing)",
track_id,
e
);
}
}
}
Ok(AlbumRemovalReport {
album_id: album_id.to_string(),
removed_track_ids: ids,
freed_bytes: bytes,
})
}
/// Pre-flight cache-limit check executed before queuing new downloads.
///
/// Returns `Ok(())` when:
/// - `limit_bytes` is `None` (no limit configured); OR
/// - the current `total_size_bytes` from the cache stats is strictly below the
/// configured limit.
///
/// Returns `Err(message)` when the cache has already met or exceeded the
/// configured limit. The message is suitable for surfacing to end users via a
/// toast.
///
/// Intentionally simple: it does not predict the new track's size. Sufficient
/// for v1 — the user gets feedback the moment they hit the limit and can free
/// space or raise the limit through the offline cache manager.
pub fn check_cache_limit(
db: &OfflineCacheDb,
offline_root: &Path,
limit_bytes: Option<u64>,
) -> Result<(), String> {
let limit = match limit_bytes {
Some(value) => value,
None => return Ok(()),
};
let stats = db.get_stats(&offline_root.to_string_lossy(), Some(limit))?;
if stats.total_size_bytes >= limit {
return Err(
"Offline cache limit reached. Free space or raise the limit in the offline cache manager."
.to_string(),
);
}
Ok(())
}
/// Filters tracks targeted by re-download: skip in-flight Downloading,
/// optionally restrict to Failed only.
pub fn select_redownload_targets(
tracks: &[CachedTrackInfo],
failed_only: bool,
) -> Vec<&CachedTrackInfo> {
tracks
.iter()
.filter(|track| match track.status {
OfflineCacheStatus::Downloading => false,
OfflineCacheStatus::Failed => true,
_ => !failed_only,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::OfflineCacheStatus;
fn track_with_status(id: u64, status: OfflineCacheStatus) -> CachedTrackInfo {
CachedTrackInfo {
track_id: id,
title: format!("t{}", id),
artist: "A".into(),
album: None,
album_id: None,
duration_secs: 0,
file_size_bytes: 0,
quality: "lossless".into(),
bit_depth: None,
sample_rate: None,
status,
progress_percent: 0,
error_message: None,
created_at: "".into(),
last_accessed_at: "".into(),
}
}
#[test]
fn redownload_targets_full_skips_only_downloading() {
let tracks = vec![
track_with_status(1, OfflineCacheStatus::Ready),
track_with_status(2, OfflineCacheStatus::Downloading),
track_with_status(3, OfflineCacheStatus::Failed),
track_with_status(4, OfflineCacheStatus::Queued),
];
let picked = select_redownload_targets(&tracks, false);
let ids: Vec<u64> = picked.iter().map(|track| track.track_id).collect();
assert_eq!(ids, vec![1, 3, 4]);
}
#[test]
fn redownload_targets_failed_only_returns_failed() {
let tracks = vec![
track_with_status(1, OfflineCacheStatus::Ready),
track_with_status(2, OfflineCacheStatus::Failed),
track_with_status(3, OfflineCacheStatus::Downloading),
];
let picked = select_redownload_targets(&tracks, true);
let ids: Vec<u64> = picked.iter().map(|track| track.track_id).collect();
assert_eq!(ids, vec![2]);
}
}
@@ -0,0 +1,236 @@
//! Offline cache path validation and management
//!
//! Handles path validation, permission checking, and mount status verification.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PathStatus {
Valid,
DoesNotExist,
NotADirectory,
NoWritePermission,
NotMounted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathValidationResult {
pub status: PathStatus,
pub message: String,
}
/// Validate an offline cache path
pub fn validate_path(path: &str) -> Result<PathValidationResult, String> {
let path_obj = Path::new(path);
if !path_obj.exists() {
return Ok(PathValidationResult {
status: PathStatus::DoesNotExist,
message: format!("Path does not exist: {}", path),
});
}
if !path_obj.is_dir() {
return Ok(PathValidationResult {
status: PathStatus::NotADirectory,
message: format!("Path is not a directory: {}", path),
});
}
if !check_mount_status(path)? {
return Ok(PathValidationResult {
status: PathStatus::NotMounted,
message: "Storage device is not mounted".to_string(),
});
}
if !check_permissions(path)? {
return Ok(PathValidationResult {
status: PathStatus::NoWritePermission,
message: "No write permission for this directory".to_string(),
});
}
Ok(PathValidationResult {
status: PathStatus::Valid,
message: "Path is valid and writable".to_string(),
})
}
/// Check if we have write permissions on a path
pub fn check_permissions(path: &str) -> Result<bool, String> {
let path_obj = Path::new(path);
let test_file = path_obj.join(".qbz_write_test");
match fs::write(&test_file, b"test") {
Ok(_) => {
let _ = fs::remove_file(&test_file);
Ok(true)
}
Err(e) => {
if e.kind() == std::io::ErrorKind::PermissionDenied {
Ok(false)
} else {
Err(format!("Failed to check permissions: {}", e))
}
}
}
}
/// Check if the path is on a mounted filesystem
pub fn check_mount_status(path: &str) -> Result<bool, String> {
let path_obj = Path::new(path);
// Try to canonicalize the path
match path_obj.canonicalize() {
Ok(canonical) => {
// If we can read the metadata, the mount is accessible
match canonical.metadata() {
Ok(_) => Ok(true),
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
Ok(false)
} else {
Err(format!("Failed to check mount status: {}", e))
}
}
}
}
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
Ok(false)
} else {
Err(format!("Failed to canonicalize path: {}", e))
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MoveReport {
pub total_files: usize,
pub moved_count: usize,
pub failed_files: Vec<String>,
}
/// Move cached files from old path to new path
pub fn move_cached_files_to_new_path(old_root: &str, new_root: &str) -> Result<MoveReport, String> {
let old_path = Path::new(old_root);
let new_path = Path::new(new_root);
// Validate new path
let validation = validate_path(new_root)?;
if !matches!(validation.status, PathStatus::Valid) {
return Err(format!("New path is not valid: {}", validation.message));
}
// Create new root if it doesn't exist
fs::create_dir_all(new_path).map_err(|e| format!("Failed to create new directory: {}", e))?;
let mut report = MoveReport {
total_files: 0,
moved_count: 0,
failed_files: Vec::new(),
};
// Collect all FLAC files recursively
let files = collect_flac_files(old_path)?;
report.total_files = files.len();
for old_file in files {
// Get relative path from old root
let relative_path = old_file
.strip_prefix(old_path)
.map_err(|e| format!("Failed to get relative path: {}", e))?;
let new_file = new_path.join(relative_path);
// Create parent directories in new location
if let Some(parent) = new_file.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
}
// Move file
match fs::rename(&old_file, &new_file) {
Ok(_) => {
report.moved_count += 1;
}
Err(e) => {
log::warn!("Failed to move file {:?}: {}", old_file, e);
report
.failed_files
.push(old_file.to_string_lossy().to_string());
}
}
}
Ok(report)
}
/// Recursively collect all FLAC files in a directory
fn collect_flac_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
let mut files = Vec::new();
if !dir.is_dir() {
return Ok(files);
}
let entries = fs::read_dir(dir).map_err(|e| format!("Failed to read directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry.path();
if path.is_dir() {
// Recurse into subdirectories
files.extend(collect_flac_files(&path)?);
} else if path.extension().and_then(|s| s.to_str()) == Some("flac") {
files.push(path);
}
}
Ok(files)
}
// Mount status cache
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
struct MountStatusCache {
path: String,
is_mounted: bool,
last_check: SystemTime,
}
static MOUNT_CACHE: Mutex<Option<MountStatusCache>> = Mutex::new(None);
const CACHE_DURATION: Duration = Duration::from_secs(30);
/// Check mount status with caching (30s cache)
pub fn is_offline_root_available(path: &str) -> Result<bool, String> {
let mut cache = MOUNT_CACHE
.lock()
.map_err(|e| format!("Cache lock error: {}", e))?;
if let Some(cached) = cache.as_ref() {
if cached.path == path {
if let Ok(elapsed) = SystemTime::now().duration_since(cached.last_check) {
if elapsed < CACHE_DURATION {
return Ok(cached.is_mounted);
}
}
}
}
// Cache miss or expired, check mount status
let is_mounted = check_mount_status(path)?;
*cache = Some(MountStatusCache {
path: path.to_string(),
is_mounted,
last_check: SystemTime::now(),
});
Ok(is_mounted)
}
@@ -0,0 +1,35 @@
//! Lazy singleton access to the `qbz-secrets::SecretBox` for the offline
//! cache.
//!
//! The vault is opened on first use (with the OS-keyring + KDF-fallback
//! selection happening inside `qbz-secrets`) and then shared across all
//! offline-cache calls. The storage directory is the app config dir — the
//! install UUID for the KDF fallback lives there so it survives upgrades.
use std::path::Path;
use std::sync::OnceLock;
use qbz_secrets::{SecretBox, SecretError};
const SERVICE_NAME: &str = "qbz";
static VAULT: OnceLock<std::sync::Mutex<Option<SecretBox>>> = OnceLock::new();
/// Get-or-init the vault. `storage_dir` is used only on the first call;
/// subsequent calls return the cached handle regardless.
pub fn get_or_init(storage_dir: &Path) -> Result<SecretBox, SecretError> {
let cell = VAULT.get_or_init(|| std::sync::Mutex::new(None));
let mut guard = cell
.lock()
.map_err(|_| SecretError::Other("secret vault mutex poisoned".to_string()))?;
if let Some(existing) = guard.as_ref() {
return Ok(existing.clone());
}
let vault = SecretBox::open(SERVICE_NAME, storage_dir)?;
log::info!(
"[OfflineCache/Vault] Opened SecretBox backend={:?}",
vault.backend_kind()
);
*guard = Some(vault.clone());
Ok(vault)
}
+107
View File
@@ -0,0 +1,107 @@
//! Shared DTOs for the offline cache (moved verbatim from
//! `src-tauri/src/offline_cache/mod.rs`). Pure serde — no Tauri, no I/O.
use serde::{Deserialize, Serialize};
/// Cache status for a track in offline storage
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OfflineCacheStatus {
Queued,
Downloading,
Ready,
Failed,
}
impl OfflineCacheStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Downloading => "downloading",
Self::Ready => "ready",
Self::Failed => "failed",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"queued" => Self::Queued,
"downloading" => Self::Downloading,
"ready" => Self::Ready,
"failed" => Self::Failed,
_ => Self::Failed,
}
}
}
/// Information about a cached track
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CachedTrackInfo {
pub track_id: u64,
pub title: String,
pub artist: String,
pub album: Option<String>,
pub album_id: Option<String>,
pub duration_secs: u64,
pub file_size_bytes: u64,
pub quality: String,
pub bit_depth: Option<u32>,
pub sample_rate: Option<f64>,
pub status: OfflineCacheStatus,
pub progress_percent: u8,
pub error_message: Option<String>,
pub created_at: String,
pub last_accessed_at: String,
}
/// Minimal track info for syncing to library
#[derive(Debug, Clone)]
pub struct ReadyTrackForSync {
pub track_id: u64,
pub title: String,
pub artist: String,
pub album: Option<String>,
pub duration_secs: u64,
pub file_path: String,
pub bit_depth: Option<u32>,
pub sample_rate: Option<f64>,
}
/// Statistics about the offline cache
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OfflineCacheStats {
pub total_tracks: usize,
pub ready_tracks: usize,
pub downloading_tracks: usize,
pub failed_tracks: usize,
pub total_size_bytes: u64,
pub limit_bytes: Option<u64>,
pub cache_path: String,
}
/// Progress update for caching a track
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheProgress {
pub track_id: u64,
pub progress_percent: u8,
pub bytes_downloaded: u64,
pub total_bytes: Option<u64>,
pub status: OfflineCacheStatus,
}
/// Track metadata for initiating offline caching
#[derive(Debug, Clone)]
pub struct TrackCacheInfo {
pub track_id: u64,
pub title: String,
pub artist: String,
pub album: Option<String>,
pub album_id: Option<String>,
pub duration_secs: u64,
pub quality: String,
pub bit_depth: Option<u32>,
pub sample_rate: Option<f64>,
}