From d6e9345f5af5d121eaef7f9460c032782729ef32 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 27 May 2026 19:08:52 -0600 Subject: [PATCH] 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 --- crates/Cargo.lock | 14 + crates/Cargo.toml | 1 + crates/qbz-offline-cache/Cargo.toml | 25 + crates/qbz-offline-cache/src/cmaf_store.rs | 238 ++++++ crates/qbz-offline-cache/src/db.rs | 750 ++++++++++++++++++ crates/qbz-offline-cache/src/lib.rs | 29 + crates/qbz-offline-cache/src/maintenance.rs | 142 ++++ .../qbz-offline-cache/src/path_validator.rs | 236 ++++++ crates/qbz-offline-cache/src/secret_vault.rs | 35 + crates/qbz-offline-cache/src/types.rs | 107 +++ 10 files changed, 1577 insertions(+) create mode 100644 crates/qbz-offline-cache/Cargo.toml create mode 100644 crates/qbz-offline-cache/src/cmaf_store.rs create mode 100644 crates/qbz-offline-cache/src/db.rs create mode 100644 crates/qbz-offline-cache/src/lib.rs create mode 100644 crates/qbz-offline-cache/src/maintenance.rs create mode 100644 crates/qbz-offline-cache/src/path_validator.rs create mode 100644 crates/qbz-offline-cache/src/secret_vault.rs create mode 100644 crates/qbz-offline-cache/src/types.rs diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 272d982a3..e6ea5d9e8 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -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" diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 425727f9c..65c0b828d 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -15,6 +15,7 @@ members = [ "qbz-cast", "qbz-cmaf", "qbz-secrets", + "qbz-offline-cache", "qconnect-core", "qconnect-protocol", "qconnect-transport-ws", diff --git a/crates/qbz-offline-cache/Cargo.toml b/crates/qbz-offline-cache/Cargo.toml new file mode 100644 index 000000000..9d7f987d8 --- /dev/null +++ b/crates/qbz-offline-cache/Cargo.toml @@ -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" diff --git a/crates/qbz-offline-cache/src/cmaf_store.rs b/crates/qbz-offline-cache/src/cmaf_store.rs new file mode 100644 index 000000000..d80ba38d4 --- /dev/null +++ b/crates/qbz-offline-cache/src/cmaf_store.rs @@ -0,0 +1,238 @@ +//! On-disk layout and I/O for v2 CMAF-bundle offline cache entries. +//! +//! Layout under `/tracks-cmaf//`: +//! +//! ```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, +} + +/// 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 = 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 { + 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::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, + pub segments: Vec>, + 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, 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::(); + 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(()) +} diff --git a/crates/qbz-offline-cache/src/db.rs b/crates/qbz-offline-cache/src/db.rs new file mode 100644 index 000000000..8763bb855 --- /dev/null +++ b/crates/qbz-offline-cache/src/db.rs @@ -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 { + 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>(7)?.unwrap_or_default(), + bit_depth: row.get::<_, Option>(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 { + 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, 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::, _>>() + .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 { + 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, 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, 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>(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, 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, 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, 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::>>() + .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, String> { + // Get file path before deleting + let file_path: Option = 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, + ) -> Result { + 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, 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, 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 = 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, 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 = 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>(2)?, + content_key_wrapped: row.get::<_, Option>>(3)?, + infos_wrapped: row.get::<_, Option>>(4)?, + format_id: row.get::<_, Option>(5)?.map(|v| v as u32), + n_segments: row.get::<_, Option>(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), String> { + let tx = self + .conn() + .unchecked_transaction() + .map_err(|e| format!("Failed to begin tx: {}", e))?; + + let ids: Vec = { + 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::>>() + .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, + pub content_key_wrapped: Option>, + pub infos_wrapped: Option>, + pub format_id: Option, + pub n_segments: Option, +} + +#[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()); + } +} diff --git a/crates/qbz-offline-cache/src/lib.rs b/crates/qbz-offline-cache/src/lib.rs new file mode 100644 index 000000000..65973208f --- /dev/null +++ b/crates/qbz-offline-cache/src/lib.rs @@ -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, +}; diff --git a/crates/qbz-offline-cache/src/maintenance.rs b/crates/qbz-offline-cache/src/maintenance.rs new file mode 100644 index 000000000..d795eaf2c --- /dev/null +++ b/crates/qbz-offline-cache/src/maintenance.rs @@ -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, + 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 { + 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, +) -> 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 = 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 = picked.iter().map(|track| track.track_id).collect(); + assert_eq!(ids, vec![2]); + } +} diff --git a/crates/qbz-offline-cache/src/path_validator.rs b/crates/qbz-offline-cache/src/path_validator.rs new file mode 100644 index 000000000..667cd2f6c --- /dev/null +++ b/crates/qbz-offline-cache/src/path_validator.rs @@ -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 { + 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 { + 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 { + 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, +} + +/// Move cached files from old path to new path +pub fn move_cached_files_to_new_path(old_root: &str, new_root: &str) -> Result { + 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, 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> = 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 { + 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) +} diff --git a/crates/qbz-offline-cache/src/secret_vault.rs b/crates/qbz-offline-cache/src/secret_vault.rs new file mode 100644 index 000000000..8a920db3a --- /dev/null +++ b/crates/qbz-offline-cache/src/secret_vault.rs @@ -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>> = 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 { + 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) +} diff --git a/crates/qbz-offline-cache/src/types.rs b/crates/qbz-offline-cache/src/types.rs new file mode 100644 index 000000000..c6250edb1 --- /dev/null +++ b/crates/qbz-offline-cache/src/types.rs @@ -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, + pub album_id: Option, + pub duration_secs: u64, + pub file_size_bytes: u64, + pub quality: String, + pub bit_depth: Option, + pub sample_rate: Option, + pub status: OfflineCacheStatus, + pub progress_percent: u8, + pub error_message: Option, + 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, + pub duration_secs: u64, + pub file_path: String, + pub bit_depth: Option, + pub sample_rate: Option, +} + +/// 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, + 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, + 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, + pub album_id: Option, + pub duration_secs: u64, + pub quality: String, + pub bit_depth: Option, + pub sample_rate: Option, +}