From c8158bf419b62053a6d565e0de9438980d48e580 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 27 May 2026 20:09:09 -0600 Subject: [PATCH] refactor(offline-cache): re-wire Tauri onto qbz-offline-cache; delete old module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes Slice 0. src-tauri now depends on the qbz-offline-cache crate; the old src-tauri/src/offline_cache/ implementation files are deleted. What remains in that module is a thin shim: a re-export of the crate plus the Tauri glue. - offline_cache/mod.rs: pub use qbz_offline_cache::* + tauri_cache_sink (maps CacheEvent -> the exact legacy offline:caching_*/offline:unlock_* app.emit shapes, so the Svelte frontend is unchanged) + core_client (resolves the core qbz_qobuz::QobuzClient from the CoreBridge for the download pipeline). - Cache/redownload/migration commands: drop the legacy crate::api::QobuzClient (state.client) + the CoreBridge double-plumb; pass one core client + an app.emit-backed CacheEventSink. Unused `state: State` params dropped. - Playback + library cached-resolution call sites: pass &tauri_cache_sink(..) to load_cmaf_bundle_with_ui_events instead of &app_handle. - session_lifecycle: apply_persisted_limit now reads the persisted value here; purge takes &library.db directly. - migration: qobuz_client is the Option-wrapped core client (consistent with the downloader); None mid-loop is recorded as a per-track failure. Full src-tauri cargo check passes; behaviour-preserving for the Svelte frontend. Backend extraction (ADR-006) done — next is Slice 1 (shared tier-walk in core). cc --- crates/qbz-offline-cache/src/migration.rs | 13 +- src-tauri/Cargo.lock | 93 ++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands_v2/legacy_compat.rs | 18 +- src-tauri/src/commands_v2/library.rs | 2 +- src-tauri/src/commands_v2/offline_cache.rs | 15 +- src-tauri/src/commands_v2/playback.rs | 6 +- src-tauri/src/offline_cache/cmaf_store.rs | 238 ------ src-tauri/src/offline_cache/db.rs | 750 ------------------ src-tauri/src/offline_cache/downloader.rs | 735 ----------------- src-tauri/src/offline_cache/maintenance.rs | 142 ---- src-tauri/src/offline_cache/metadata.rs | 367 --------- src-tauri/src/offline_cache/migration.rs | 252 ------ src-tauri/src/offline_cache/mod.rs | 387 +++------ src-tauri/src/offline_cache/path_validator.rs | 236 ------ src-tauri/src/offline_cache/playback.rs | 169 ---- src-tauri/src/offline_cache/purge.rs | 99 --- src-tauri/src/offline_cache/secret_vault.rs | 35 - src-tauri/src/session_lifecycle.rs | 14 +- 19 files changed, 213 insertions(+), 3359 deletions(-) delete mode 100644 src-tauri/src/offline_cache/cmaf_store.rs delete mode 100644 src-tauri/src/offline_cache/db.rs delete mode 100644 src-tauri/src/offline_cache/downloader.rs delete mode 100644 src-tauri/src/offline_cache/maintenance.rs delete mode 100644 src-tauri/src/offline_cache/metadata.rs delete mode 100644 src-tauri/src/offline_cache/migration.rs delete mode 100644 src-tauri/src/offline_cache/path_validator.rs delete mode 100644 src-tauri/src/offline_cache/playback.rs delete mode 100644 src-tauri/src/offline_cache/purge.rs delete mode 100644 src-tauri/src/offline_cache/secret_vault.rs diff --git a/crates/qbz-offline-cache/src/migration.rs b/crates/qbz-offline-cache/src/migration.rs index aaae5f18d..eab8a9390 100644 --- a/crates/qbz-offline-cache/src/migration.rs +++ b/crates/qbz-offline-cache/src/migration.rs @@ -175,7 +175,7 @@ pub async fn migrate_legacy_cached_files( track_ids: Vec, tracks_dir: PathBuf, offline_root: String, - qobuz_client: Arc>, + qobuz_client: Arc>>, library_db: Arc>>, ) -> MigrationStatus { let total = track_ids.len(); @@ -197,12 +197,21 @@ pub async fn migrate_legacy_cached_files( // Lock client for this migration let client_guard = qobuz_client.read().await; + let Some(client) = client_guard.as_ref() else { + status.failed += 1; + status.errors.push(MigrationError { + track_id, + error_message: "QobuzClient not initialized".to_string(), + }); + status.processed += 1; + continue; + }; match migrate_single_track( track_id, legacy_path.clone(), &offline_root, - &*client_guard, + client, library_db.clone(), ) .await diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 26f0aa7a6..aa0f46682 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1499,13 +1499,34 @@ dependencies = [ "crypto-common 0.2.1", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -1516,7 +1537,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2006,7 +2027,7 @@ dependencies = [ "core-foundation 0.9.4", "core-graphics 0.23.2", "core-text", - "dirs", + "dirs 6.0.0", "dwrote", "float-ord", "freetype-sys", @@ -4979,7 +5000,7 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "chrono", - "dirs", + "dirs 6.0.0", "log", "md-5", "qbz-audio", @@ -5001,7 +5022,7 @@ dependencies = [ "alsa-sys", "async-trait", "coreaudio-rs", - "dirs", + "dirs 6.0.0", "ebur128", "log", "objc2-core-audio", @@ -5021,7 +5042,7 @@ dependencies = [ name = "qbz-cache" version = "0.1.0" dependencies = [ - "dirs", + "dirs 6.0.0", "filetime", "log", "md-5", @@ -5072,6 +5093,7 @@ dependencies = [ "qbz-models", "qbz-player", "qbz-qobuz", + "qbz-radio", "serde", "serde_json", "thiserror 2.0.18", @@ -5085,7 +5107,7 @@ dependencies = [ "aes-gcm", "ashpd", "base64 0.22.1", - "dirs", + "dirs 6.0.0", "keyring", "log", "rand 0.10.1", @@ -5114,7 +5136,7 @@ dependencies = [ name = "qbz-library" version = "0.1.0" dependencies = [ - "dirs", + "dirs 6.0.0", "glob", "image", "lofty", @@ -5151,7 +5173,7 @@ dependencies = [ "axum-server", "base64 0.22.1", "chrono", - "dirs", + "dirs 6.0.0", "discord-rich-presence", "dotenvy", "ebur128", @@ -5179,6 +5201,7 @@ dependencies = [ "qbz-integrations", "qbz-library", "qbz-models", + "qbz-offline-cache", "qbz-player", "qbz-qobuz", "qbz-secrets", @@ -5220,6 +5243,26 @@ dependencies = [ "walkdir", ] +[[package]] +name = "qbz-offline-cache" +version = "0.1.0" +dependencies = [ + "dirs 6.0.0", + "futures-util", + "lofty", + "log", + "qbz-cmaf", + "qbz-library", + "qbz-models", + "qbz-qobuz", + "qbz-secrets", + "reqwest 0.12.28", + "rusqlite", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "qbz-player" version = "0.1.0" @@ -5262,6 +5305,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "qbz-radio" +version = "0.1.0" +dependencies = [ + "dirs 5.0.1", + "log", + "qbz-models", + "qbz-qobuz", + "rusqlite", +] + [[package]] name = "qbz-secrets" version = "0.1.0" @@ -5622,6 +5676,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -7027,7 +7092,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -7078,7 +7143,7 @@ checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -7300,7 +7365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", @@ -7898,7 +7963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -9309,7 +9374,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 858b44547..acc3570e6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,6 +38,7 @@ qbz-library = { path = "../crates/qbz-library" } qbz-cast = { path = "../crates/qbz-cast" } qbz-cmaf = { path = "../crates/qbz-cmaf" } qbz-secrets = { path = "../crates/qbz-secrets" } +qbz-offline-cache = { path = "../crates/qbz-offline-cache" } qconnect-app = { path = "../crates/qconnect-app" } qconnect-core = { path = "../crates/qconnect-core" } qconnect-transport-ws = { path = "../crates/qconnect-transport-ws" } diff --git a/src-tauri/src/commands_v2/legacy_compat.rs b/src-tauri/src/commands_v2/legacy_compat.rs index 538edb736..a63ea4adf 100644 --- a/src-tauri/src/commands_v2/legacy_compat.rs +++ b/src-tauri/src/commands_v2/legacy_compat.rs @@ -896,7 +896,6 @@ pub async fn v2_cache_track_for_offline( quality: String, bit_depth: Option, sample_rate: Option, - state: State<'_, AppState>, bridge: State<'_, crate::core_bridge::CoreBridgeState>, cache_state: State<'_, OfflineCacheState>, app_handle: tauri::AppHandle, @@ -933,16 +932,16 @@ pub async fn v2_cache_track_for_offline( db.insert_track(&track_info, &file_path_str)?; } + let core_client = crate::offline_cache::core_client(&bridge).await?; spawn_track_cache_download( track_id, file_path, - state.client.clone(), - bridge.0.clone(), + core_client, cache_state.fetcher.clone(), cache_state.db.clone(), cache_state.get_cache_path(), cache_state.library_db.clone(), - app_handle.clone(), + crate::offline_cache::tauri_cache_sink(app_handle.clone()), cache_state.cache_semaphore.clone(), ); @@ -966,7 +965,6 @@ pub struct BatchTrackInfo { #[tauri::command] pub async fn v2_cache_tracks_batch_for_offline( tracks: Vec, - state: State<'_, AppState>, bridge: State<'_, crate::core_bridge::CoreBridgeState>, cache_state: State<'_, OfflineCacheState>, app_handle: tauri::AppHandle, @@ -1016,18 +1014,18 @@ pub async fn v2_cache_tracks_batch_for_offline( } // Spawn download tasks for each track + let core_client = crate::offline_cache::core_client(&bridge).await?; for track in &tracks { let file_path = cache_state.track_file_path(track.id, "flac"); spawn_track_cache_download( track.id, file_path, - state.client.clone(), - bridge.0.clone(), + core_client.clone(), cache_state.fetcher.clone(), cache_state.db.clone(), cache_state.get_cache_path(), cache_state.library_db.clone(), - app_handle.clone(), + crate::offline_cache::tauri_cache_sink(app_handle.clone()), cache_state.cache_semaphore.clone(), ); } @@ -1037,7 +1035,7 @@ pub async fn v2_cache_tracks_batch_for_offline( #[tauri::command] pub async fn v2_start_legacy_migration( - state: State<'_, AppState>, + bridge: State<'_, crate::core_bridge::CoreBridgeState>, cache_state: State<'_, OfflineCacheState>, library_state: State<'_, crate::library::LibraryState>, app_handle: tauri::AppHandle, @@ -1051,7 +1049,7 @@ pub async fn v2_start_legacy_migration( } let offline_root = cache_state.get_cache_path(); - let qobuz_client = state.client.clone(); + let qobuz_client = crate::offline_cache::core_client(&bridge).await?; let library_db = library_state.db.clone(); let app_complete = app_handle.clone(); diff --git a/src-tauri/src/commands_v2/library.rs b/src-tauri/src/commands_v2/library.rs index 6ccc3ea05..1f96680bf 100644 --- a/src-tauri/src/commands_v2/library.rs +++ b/src-tauri/src/commands_v2/library.rs @@ -924,7 +924,7 @@ pub async fn v2_library_play_track( // CMAF id = qobuz track id (what the bundle is keyed by). let audio_data = crate::offline_cache::playback::load_cmaf_bundle_with_ui_events( - &app_handle, + &crate::offline_cache::tauri_cache_sink(app_handle.clone()), track_id as u64, qobuz_track_id as u64, row.clone(), diff --git a/src-tauri/src/commands_v2/offline_cache.rs b/src-tauri/src/commands_v2/offline_cache.rs index 60c9fd25c..9b254dfde 100644 --- a/src-tauri/src/commands_v2/offline_cache.rs +++ b/src-tauri/src/commands_v2/offline_cache.rs @@ -9,7 +9,6 @@ use crate::core_bridge::CoreBridgeState; use crate::offline_cache::{ downloader::spawn_track_cache_download, maintenance, OfflineCacheState, OfflineCacheStatus, }; -use crate::AppState; #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -49,7 +48,6 @@ pub async fn v2_redownload_cached_album( album_id: String, failed_only: bool, cache_state: State<'_, OfflineCacheState>, - state: State<'_, AppState>, bridge: State<'_, CoreBridgeState>, app_handle: tauri::AppHandle, ) -> Result { @@ -72,18 +70,18 @@ pub async fn v2_redownload_cached_album( ids }; + let core_client = crate::offline_cache::core_client(&bridge).await?; for track_id in &queued_ids { let file_path = cache_state.track_file_path(*track_id, "flac"); spawn_track_cache_download( *track_id, file_path, - state.client.clone(), - bridge.0.clone(), + core_client.clone(), cache_state.fetcher.clone(), cache_state.db.clone(), offline_root_string.clone(), cache_state.library_db.clone(), - app_handle.clone(), + crate::offline_cache::tauri_cache_sink(app_handle.clone()), cache_state.cache_semaphore.clone(), ); } @@ -98,7 +96,6 @@ pub async fn v2_redownload_cached_album( pub async fn v2_redownload_cached_track( track_id: u64, cache_state: State<'_, OfflineCacheState>, - state: State<'_, AppState>, bridge: State<'_, CoreBridgeState>, app_handle: tauri::AppHandle, ) -> Result<(), String> { @@ -119,16 +116,16 @@ pub async fn v2_redownload_cached_track( } let file_path = cache_state.track_file_path(track_id, "flac"); + let core_client = crate::offline_cache::core_client(&bridge).await?; spawn_track_cache_download( track_id, file_path, - state.client.clone(), - bridge.0.clone(), + core_client, cache_state.fetcher.clone(), cache_state.db.clone(), offline_root_string, cache_state.library_db.clone(), - app_handle, + crate::offline_cache::tauri_cache_sink(app_handle), cache_state.cache_semaphore.clone(), ); diff --git a/src-tauri/src/commands_v2/playback.rs b/src-tauri/src/commands_v2/playback.rs index 0b04060c4..11245c57f 100644 --- a/src-tauri/src/commands_v2/playback.rs +++ b/src-tauri/src/commands_v2/playback.rs @@ -658,7 +658,7 @@ pub async fn v2_play_next_gapless( let cache_path = offline_cache.get_cache_path(); let decrypted = crate::offline_cache::playback::load_cmaf_bundle_with_ui_events( - &app_handle, + &crate::offline_cache::tauri_cache_sink(app_handle.clone()), track_id, track_id, row.clone(), @@ -741,7 +741,7 @@ pub async fn v2_play_next_gapless( let cache_path = offline_cache.get_cache_path(); let decrypted = crate::offline_cache::playback::load_cmaf_bundle_with_ui_events( - &app_handle, + &crate::offline_cache::tauri_cache_sink(app_handle.clone()), track_id, // display: library row id qid as u64, // cmaf/bundle: qobuz id row.clone(), @@ -1123,7 +1123,7 @@ pub async fn v2_play_track( let audio_data_opt: Option> = if row.cache_format == 2 { let cache_path = offline_cache.get_cache_path(); crate::offline_cache::playback::load_cmaf_bundle_with_ui_events( - &app_handle, + &crate::offline_cache::tauri_cache_sink(app_handle.clone()), track_id, track_id, row.clone(), diff --git a/src-tauri/src/offline_cache/cmaf_store.rs b/src-tauri/src/offline_cache/cmaf_store.rs deleted file mode 100644 index d80ba38d4..000000000 --- a/src-tauri/src/offline_cache/cmaf_store.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! 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/src-tauri/src/offline_cache/db.rs b/src-tauri/src/offline_cache/db.rs deleted file mode 100644 index 73777771b..000000000 --- a/src-tauri/src/offline_cache/db.rs +++ /dev/null @@ -1,750 +0,0 @@ -//! SQLite database for offline cache index - -use rusqlite::{params, Connection}; -use std::path::Path; - -use super::{ - 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/src-tauri/src/offline_cache/downloader.rs b/src-tauri/src/offline_cache/downloader.rs deleted file mode 100644 index 82adf0413..000000000 --- a/src-tauri/src/offline_cache/downloader.rs +++ /dev/null @@ -1,735 +0,0 @@ -//! Stream fetcher for caching tracks to disk - -use std::io::Write; -use std::path::Path; -use std::time::{Duration, Instant}; -use tauri::{AppHandle, Emitter}; - -use super::{CacheProgress, OfflineCacheStatus}; - -/// Maximum number of download retry attempts -const MAX_RETRIES: u32 = 3; - -/// Backoff durations for each retry attempt -const RETRY_BACKOFFS: [Duration; 3] = [ - Duration::from_secs(1), - Duration::from_secs(3), - Duration::from_secs(5), -]; - -/// StreamFetcher handles fetching audio streams and caching them to disk. -/// -/// Creates a fresh HTTP client per download to avoid HTTP/2 connection pool -/// poisoning: when a CDN connection breaks mid-transfer, a persistent client's -/// pool can keep reusing the dead connection, causing all subsequent downloads -/// to fail after 1 byte. Ephemeral clients guarantee a clean connection pool. -pub struct StreamFetcher; - -impl StreamFetcher { - pub fn new() -> Self { - Self - } - - /// Build a fresh reqwest::Client for a single download. - /// - /// Each download gets its own client to prevent HTTP/2 connection pool - /// poisoning from affecting subsequent downloads. - fn build_client() -> Result { - reqwest::Client::builder() - .timeout(Duration::from_secs(300)) // 5 minute timeout for large files - .connect_timeout(Duration::from_secs(15)) - .use_native_tls() - .build() - .map_err(|e| format!("Failed to create HTTP client: {}", e)) - } - - /// Fetch a stream and cache it to disk with progress updates. - /// - /// Retries up to MAX_RETRIES times with exponential backoff on transient - /// failures (connection reset, EOF, timeout). Each retry creates a fresh - /// HTTP client to avoid reusing a poisoned connection pool. - pub async fn fetch_to_file( - &self, - url: &str, - dest_path: &Path, - track_id: u64, - app_handle: Option<&AppHandle>, - ) -> Result { - log::info!("Caching track {} to {:?}", track_id, dest_path); - - // Create parent directories if needed - if let Some(parent) = dest_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create directory: {}", e))?; - } - - let temp_path = dest_path.with_extension("tmp"); - - let mut last_error = String::new(); - for attempt in 0..=MAX_RETRIES { - if attempt > 0 { - let backoff = RETRY_BACKOFFS[(attempt - 1) as usize]; - log::info!( - "[Offline] Retry {}/{} for track {} after {}s", - attempt, - MAX_RETRIES, - track_id, - backoff.as_secs() - ); - tokio::time::sleep(backoff).await; - } - - // Fresh client per attempt — prevents connection pool poisoning - let client = Self::build_client()?; - - match self - .try_download(&client, url, &temp_path, track_id, app_handle) - .await - { - Ok(size) => { - // Move temp file to final destination - std::fs::rename(&temp_path, dest_path) - .map_err(|e| format!("Failed to move temp file: {}", e))?; - log::info!("Caching complete for track {}: {} bytes", track_id, size); - return Ok(size); - } - Err(e) => { - last_error = e; - // Clean up partial temp file before retry - let _ = std::fs::remove_file(&temp_path); - if attempt < MAX_RETRIES { - log::warn!( - "[Offline] Download attempt {} failed for track {}: {}", - attempt + 1, - track_id, - last_error - ); - } - } - } - } - - Err(last_error) - } - - /// Single download attempt: stream response body to a temp file. - async fn try_download( - &self, - client: &reqwest::Client, - url: &str, - temp_path: &Path, - track_id: u64, - app_handle: Option<&AppHandle>, - ) -> Result { - let response = client - .get(url) - .header("User-Agent", "Mozilla/5.0") - .send() - .await - .map_err(|e| format!("Failed to start fetch: {}", e))?; - - if !response.status().is_success() { - return Err(format!("HTTP error: {}", response.status())); - } - - let total_size = response.content_length(); - log::info!( - "Caching started for track {}, total size: {:?} bytes", - track_id, - total_size - ); - - let mut file = std::fs::File::create(temp_path) - .map_err(|e| format!("Failed to create temp file: {}", e))?; - - let mut cached: u64 = 0; - let mut last_progress: u8 = 0; - let mut last_emit_time = Instant::now(); - const MIN_EMIT_INTERVAL: Duration = Duration::from_millis(200); - - let mut stream = response.bytes_stream(); - use futures_util::StreamExt; - - while let Some(chunk_result) = stream.next().await { - let chunk = chunk_result.map_err(|e| { - use std::error::Error as _; - let mut msg = format!("Fetch error: {}", e); - let mut source = e.source(); - while let Some(cause) = source { - msg.push_str(&format!(" | caused by: {}", cause)); - source = cause.source(); - } - log::error!( - "[Offline] Download error for track {} after {} bytes: {}", - track_id, - cached, - msg - ); - msg - })?; - - file.write_all(&chunk) - .map_err(|e| format!("Failed to write chunk: {}", e))?; - - cached += chunk.len() as u64; - - // Calculate progress - let progress = if let Some(total) = total_size { - ((cached as f64 / total as f64) * 100.0) as u8 - } else { - 0 - }; - - // Emit progress event every 2% change AND at least 200ms apart (always emit 100%) - let elapsed = last_emit_time.elapsed(); - if progress != last_progress - && (progress - last_progress >= 2 || progress == 100) - && (elapsed >= MIN_EMIT_INTERVAL || progress == 100) - { - last_progress = progress; - last_emit_time = Instant::now(); - - if let Some(app) = app_handle { - let _ = app.emit( - "offline:caching_progress", - CacheProgress { - track_id, - progress_percent: progress, - bytes_downloaded: cached, - total_bytes: total_size, - status: OfflineCacheStatus::Downloading, - }, - ); - } - - log::debug!( - "Caching progress for track {}: {}% ({}/{:?} bytes)", - track_id, - progress, - cached, - total_size - ); - } - } - - // Ensure all data is written - file.flush() - .map_err(|e| format!("Failed to flush file: {}", e))?; - drop(file); - - Ok(cached) - } - - /// Fetch to memory (for smaller files or streaming) - pub async fn fetch_to_memory(&self, url: &str) -> Result, String> { - let client = Self::build_client()?; - - let response = client - .get(url) - .header("User-Agent", "Mozilla/5.0") - .send() - .await - .map_err(|e| format!("Failed to fetch: {}", e))?; - - if !response.status().is_success() { - return Err(format!("HTTP error: {}", response.status())); - } - - let bytes = response - .bytes() - .await - .map_err(|e| format!("Failed to read bytes: {}", e))?; - - Ok(bytes.to_vec()) - } -} - -impl Default for StreamFetcher { - fn default() -> Self { - Self::new() - } -} - -/// Shared helper: spawn the download task for a single track. -/// CMAF-first offline download path (v2 format). -/// -/// On success: the encrypted CMAF bundle is persisted under -/// `/tracks-cmaf//`, the per-track AES content key -/// + session infos are wrapped via `qbz-secrets` and stored on the DB row, -/// `cache_format` flips to 2, `mark_complete` fires, and the library row -/// is populated with the same metadata the legacy path would populate -/// (title/artist/album, etc.). -/// -/// Returns `Err` for any failure that makes CMAF unusable — the caller -/// falls back to the legacy plain-FLAC path. -pub(crate) async fn try_cmaf_offline_download( - track_id: u64, - bridge: &std::sync::Arc>>, - db: &std::sync::Arc>>, - offline_root: &str, - library_db: &std::sync::Arc>>, - client: &std::sync::Arc>, - app: &tauri::AppHandle, -) -> Result<(), String> { - use qbz_models::Quality; - - let offline_root_path = std::path::PathBuf::from(offline_root); - - // Progress callback: emit the same `offline:caching_progress` event - // shape the legacy StreamFetcher fires, so the UI's progress ring - // doesn't care whether the bytes came from CMAF or legacy. - // - // Note: one 'started' event here up front so the frontend sees - // 'downloading' status immediately (the ring starts empty); actual - // percentage updates arrive per completed segment. - let _ = app.emit( - "offline:caching_progress", - serde_json::json!({ - "trackId": track_id, - "progressPercent": 0u32, - "bytesDownloaded": 0u64, - "totalBytes": serde_json::Value::Null, - "status": "downloading", - }), - ); - - let app_for_cb = app.clone(); - let progress_cb: qbz_qobuz::CmafProgressCallback = std::sync::Arc::new( - move |update: qbz_qobuz::CmafProgressUpdate| { - let percent = if update.n_segments > 0 { - (update.segments_completed as f64 / update.n_segments as f64 * 100.0) - .round() - .clamp(0.0, 100.0) as u32 - } else { - 0u32 - }; - let _ = app_for_cb.emit( - "offline:caching_progress", - serde_json::json!({ - "trackId": track_id, - "progressPercent": percent, - "bytesDownloaded": update.bytes_this_segment, - "totalBytes": serde_json::Value::Null, - "status": "downloading", - }), - ); - }, - ); - - // Fetch the raw CMAF bundle. Requires an initialized CoreBridge → - // QobuzClient; if either is missing, bail so the legacy path runs. - let bundle = { - let bridge_guard = bridge.read().await; - let bridge = bridge_guard - .as_ref() - .ok_or_else(|| "CoreBridge not initialized".to_string())?; - let client_arc = bridge.core().client(); - let client_guard = client_arc.read().await; - let qobuz_client = client_guard - .as_ref() - .ok_or_else(|| "QobuzClient not initialized".to_string())?; - qbz_qobuz::cmaf::download_raw_with_progress( - qobuz_client, - track_id, - Quality::UltraHiRes, - Some(progress_cb), - ) - .await? - }; - - // Open (or lazily init) the secret vault and wrap the keying material - // before it touches the filesystem. - let vault = crate::offline_cache::secret_vault::get_or_init(&offline_root_path) - .map_err(|e| format!("SecretBox init failed: {}", e))?; - let content_key_wrapped = vault - .wrap(&bundle.content_key) - .map_err(|e| format!("Failed to wrap content_key: {}", e))?; - let infos_wrapped = vault - .wrap(bundle.infos.as_bytes()) - .map_err(|e| format!("Failed to wrap infos: {}", e))?; - - // Persist the encrypted bundle to disk. - let (layout, total_bytes) = - crate::offline_cache::cmaf_store::persist_bundle(&offline_root_path, track_id, &bundle)?; - - // Flip the DB row to v2 and store the wrapped keying material. - { - let db_guard = db.lock().await; - let db_ref = db_guard - .as_ref() - .ok_or_else(|| "Offline cache DB not open".to_string())?; - db_ref.set_cmaf_bundle( - track_id, - layout.segments_path.to_string_lossy().as_ref(), - layout.init_path.to_string_lossy().as_ref(), - &content_key_wrapped, - &infos_wrapped, - bundle.format_id, - bundle.n_segments as u32, - total_bytes, - )?; - db_ref - .mark_complete(track_id, total_bytes) - .map_err(|e| format!("Failed to mark_complete: {}", e))?; - } - - // Fetch metadata for the library row (same source the legacy path uses). - // We don't write FLAC tags or embed artwork INSIDE the encrypted blob - // — that would corrupt it. But we DO save a cover.jpg next to the - // bundle directory so the library UI has artwork to display. - let metadata = { - let qobuz_client = client.read().await; - crate::offline_cache::metadata::fetch_complete_metadata(track_id, &*qobuz_client).await - }; - if let Ok(metadata) = metadata { - // Download and save album artwork alongside the bundle, same as - // the legacy path does next to the FLAC file. cover.jpg lives at - // /tracks-cmaf//cover.jpg — set as the - // library row's artwork_path so the UI picks it up. - let artwork_path: Option = if let Some(artwork_url) = metadata.artwork_url.as_deref() { - match crate::offline_cache::metadata::save_album_artwork(&layout.track_dir, artwork_url).await { - Ok(()) => { - let cover = layout.track_dir.join("cover.jpg"); - if cover.exists() { - Some(cover.to_string_lossy().to_string()) - } else { - None - } - } - Err(e) => { - log::warn!("[Offline/CMAF] Track {} artwork save failed: {}", track_id, e); - None - } - } - } else { - None - }; - - let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist); - let album_group_key = format!("{}|{}", metadata.album, album_artist); - let lib_opt = library_db.lock().await; - if let Some(lib_guard) = lib_opt.as_ref() { - let _ = lib_guard.insert_qobuz_cached_track_with_grouping( - track_id, - &metadata.title, - &metadata.artist, - Some(&metadata.album), - metadata.album_artist.as_deref(), - metadata.track_number, - metadata.disc_number, - metadata.year, - metadata.duration_secs, - // For v2 bundles the "playable path" in the library index - // is the track directory; the player resolves it through - // the DB's cache_format=2 branch anyway. - layout.track_dir.to_string_lossy().as_ref(), - &album_group_key, - &metadata.album, - bundle.bit_depth, - bundle.sampling_rate.map(|r| r as f64), - artwork_path.as_deref(), - ); - } - } else if let Err(e) = metadata { - log::warn!( - "[Offline/CMAF] Track {} post-metadata fetch failed: {} (bundle already persisted)", - track_id, - e - ); - } - - log::info!( - "[Offline/CMAF] Track {} cached as v2 bundle: {:.2} MB under {:?}", - track_id, - total_bytes as f64 / (1024.0 * 1024.0), - layout.track_dir - ); - let _ = app.emit( - "offline:caching_completed", - serde_json::json!({ - "trackId": track_id, - "size": total_bytes, - "format": "cmaf", - }), - ); - let _ = app.emit( - "offline:caching_processed", - serde_json::json!({ - "trackId": track_id, - "path": layout.track_dir.to_string_lossy(), - "format": "cmaf", - }), - ); - Ok(()) -} - -/// Used by both v2_cache_track_for_offline (single) and v2_cache_tracks_batch_for_offline (batch). -#[allow(clippy::too_many_arguments)] -pub(crate) fn spawn_track_cache_download( - track_id: u64, - file_path: std::path::PathBuf, - client: std::sync::Arc>, - bridge: std::sync::Arc>>, - fetcher: std::sync::Arc, - db: std::sync::Arc>>, - offline_root: String, - library_db: std::sync::Arc>>, - app: tauri::AppHandle, - semaphore: std::sync::Arc, -) { - tokio::spawn(async move { - let _permit = match semaphore.acquire_owned().await { - Ok(permit) => permit, - Err(err) => { - log::error!( - "Failed to acquire cache slot for track {}: {}", - track_id, - err - ); - if let Some(db_guard) = db.lock().await.as_ref() { - let _ = db_guard.update_status( - track_id, - crate::offline_cache::OfflineCacheStatus::Failed, - Some("Failed to start caching"), - ); - } - let _ = app.emit( - "offline:caching_failed", - serde_json::json!({ - "trackId": track_id, - "error": "Failed to acquire cache slot" - }), - ); - return; - } - }; - - if let Some(db_guard) = db.lock().await.as_ref() { - let _ = db_guard.update_status( - track_id, - crate::offline_cache::OfflineCacheStatus::Downloading, - None, - ); - } - let _ = app.emit( - "offline:caching_started", - serde_json::json!({ "trackId": track_id }), - ); - - // === CMAF-first offline download (v2 format) === - // - // Stores bit-identical encrypted segments + wrapped content key. - // Falls through to the legacy path below if any step fails (no - // CoreBridge yet, /file/url returns a non-CMAF response, network - // flake, vault init failure, etc.). The legacy fallback keeps - // existing users unblocked while we validate the new path. - match try_cmaf_offline_download( - track_id, - &bridge, - &db, - &offline_root, - &library_db, - &client, - &app, - ) - .await - { - Ok(()) => return, - Err(e) => { - log::warn!( - "[Offline/CMAF] Track {} — CMAF path failed ({}), falling back to legacy /track/getFileUrl", - track_id, - e - ); - } - } - - let stream_url = { - let client_guard = client.read().await; - client_guard - .get_stream_url_with_fallback(track_id, crate::api::models::Quality::UltraHiRes) - .await - }; - - let url = match stream_url { - Ok(s) => s.url, - Err(e) => { - log::error!("Failed to get stream URL for track {}: {}", track_id, e); - if let Some(db_guard) = db.lock().await.as_ref() { - let _ = db_guard.update_status( - track_id, - crate::offline_cache::OfflineCacheStatus::Failed, - Some(&format!("Failed to get stream URL: {}", e)), - ); - } - let _ = app.emit( - "offline:caching_failed", - serde_json::json!({ - "trackId": track_id, - "error": e.to_string() - }), - ); - return; - } - }; - - match fetcher - .fetch_to_file(&url, &file_path, track_id, Some(&app)) - .await - { - Ok(size) => { - log::info!("Caching complete for track {}: {} bytes", track_id, size); - if let Some(db_guard) = db.lock().await.as_ref() { - let _ = db_guard.mark_complete(track_id, size); - } - let _ = app.emit( - "offline:caching_completed", - serde_json::json!({ - "trackId": track_id, - "size": size - }), - ); - - // Post-processing kept in V2 to avoid command->command delegation. - let file_path_str = file_path.to_string_lossy().to_string(); - let qobuz_client = client.read().await; - let metadata = match crate::offline_cache::metadata::fetch_complete_metadata( - track_id, - &*qobuz_client, - ) - .await - { - Ok(m) => m, - Err(e) => { - log::warn!( - "Post-processing metadata fetch failed for {}: {}", - track_id, - e - ); - return; - } - }; - - if let Err(e) = - crate::offline_cache::metadata::write_flac_tags(&file_path_str, &metadata) - { - log::warn!("Failed to write tags for {}: {}", track_id, e); - } - if let Some(artwork_url) = &metadata.artwork_url { - if let Err(e) = - crate::offline_cache::metadata::embed_artwork(&file_path_str, artwork_url) - .await - { - log::warn!("Failed to embed artwork for {}: {}", track_id, e); - } - } - - let new_path = match crate::offline_cache::metadata::organize_cached_file( - track_id, - &file_path_str, - &offline_root, - &metadata, - ) { - Ok(p) => p, - Err(e) => { - log::warn!("Failed to organize cached file {}: {}", track_id, e); - return; - } - }; - - // Save cover.jpg next to the organized FLAC so the library - // UI has artwork to display. - let artwork_path_v1: Option = - if let Some(artwork_url) = metadata.artwork_url.as_deref() { - if let Some(parent_dir) = std::path::Path::new(&new_path).parent() { - match crate::offline_cache::metadata::save_album_artwork( - parent_dir, - artwork_url, - ) - .await - { - Ok(()) => { - let cover = parent_dir.join("cover.jpg"); - if cover.exists() { - Some(cover.to_string_lossy().to_string()) - } else { - None - } - } - Err(_) => None, - } - } else { - None - } - } else { - None - }; - - let (bit_depth_detected, sample_rate_detected) = - match lofty::read_from_path(&new_path) { - Ok(tagged_file) => { - use lofty::prelude::*; - let properties = tagged_file.properties(); - ( - properties.bit_depth().map(|bd| bd as u32), - properties.sample_rate().map(|sr| sr as f64), - ) - } - Err(_) => (None, None), - }; - - let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist); - let album_group_key = format!("{}|{}", metadata.album, album_artist); - let lib_opt = library_db.lock().await; - if let Some(lib_guard) = lib_opt.as_ref() { - let _ = lib_guard.insert_qobuz_cached_track_with_grouping( - track_id, - &metadata.title, - &metadata.artist, - Some(&metadata.album), - metadata.album_artist.as_deref(), - metadata.track_number, - metadata.disc_number, - metadata.year, - metadata.duration_secs, - &new_path, - &album_group_key, - &metadata.album, - bit_depth_detected, - sample_rate_detected, - artwork_path_v1.as_deref(), - ); - } - - if let Some(db_guard) = db.lock().await.as_ref() { - let _ = db_guard.update_file_path(track_id, &new_path); - } - - let _ = app.emit( - "offline:caching_processed", - serde_json::json!({ - "trackId": track_id, - "path": new_path - }), - ); - } - Err(e) => { - log::error!("Caching failed for track {}: {}", track_id, e); - if let Some(db_guard) = db.lock().await.as_ref() { - let _ = db_guard.update_status( - track_id, - crate::offline_cache::OfflineCacheStatus::Failed, - Some(&e), - ); - } - let _ = app.emit( - "offline:caching_failed", - serde_json::json!({ - "trackId": track_id, - "error": e - }), - ); - } - } - }); -} diff --git a/src-tauri/src/offline_cache/maintenance.rs b/src-tauri/src/offline_cache/maintenance.rs deleted file mode 100644 index e1502bb07..000000000 --- a/src-tauri/src/offline_cache/maintenance.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! 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 super::cmaf_store::BundleLayout; -use super::{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::offline_cache::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/src-tauri/src/offline_cache/metadata.rs b/src-tauri/src/offline_cache/metadata.rs deleted file mode 100644 index 78412962c..000000000 --- a/src-tauri/src/offline_cache/metadata.rs +++ /dev/null @@ -1,367 +0,0 @@ -//! Metadata fetching and FLAC tagging for cached tracks -//! -//! Handles complete metadata retrieval from Qobuz API and writing to FLAC tags. - -use lofty::config::WriteOptions; -use lofty::picture::{MimeType, Picture, PictureType}; -use lofty::prelude::*; -use lofty::tag::{ItemKey, Tag}; -use serde::{Deserialize, Serialize}; -use std::path::Path; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CompleteTrackMetadata { - pub track_id: u64, - pub title: String, - pub artist: String, - pub album: String, - pub album_artist: Option, - pub track_number: Option, - pub disc_number: Option, - pub year: Option, - pub genre: Option, - pub isrc: Option, - pub label: Option, - pub copyright: Option, - pub composer: Option, - pub duration_secs: u64, - pub artwork_url: Option, -} - -/// Fetch complete metadata for a track from Qobuz API -pub async fn fetch_complete_metadata( - track_id: u64, - qobuz_client: &crate::api::QobuzClient, -) -> Result { - log::info!("Fetching complete metadata for track {}", track_id); - - let track = qobuz_client - .get_track(track_id) - .await - .map_err(|e| format!("Failed to fetch track: {}", e))?; - - let album = if let Some(album_obj) = &track.album { - qobuz_client.get_album(&album_obj.id).await.ok() - } else { - None - }; - - let album_artist = album - .as_ref() - .map(|a| a.artist.name.clone()) - .or_else(|| track.performer.as_ref().map(|p| p.name.clone())); - - let genre = album - .as_ref() - .and_then(|a| a.genre.as_ref()) - .map(|g| g.name.clone()); - - let label = album - .as_ref() - .and_then(|a| a.label.as_ref()) - .map(|l| l.name.clone()); - - let year = album - .as_ref() - .and_then(|a| a.release_date_original.as_ref()) - .and_then(|date_str| { - // Parse YYYY-MM-DD or YYYY format - date_str - .split('-') - .next() - .and_then(|year_str| year_str.parse::().ok()) - }); - - let artwork_url = album - .as_ref() - .and_then(|a| a.image.large.clone()) - .or_else(|| track.album.as_ref().and_then(|a| a.image.large.clone())); - - Ok(CompleteTrackMetadata { - track_id, - title: track.title, - artist: track - .performer - .as_ref() - .map(|p| p.name.clone()) - .unwrap_or_default(), - album: track - .album - .as_ref() - .map(|a| a.title.clone()) - .unwrap_or_default(), - album_artist, - track_number: Some(track.track_number), - disc_number: track.media_number, - year, - genre, - isrc: track.isrc.clone(), - label, - copyright: None, // Qobuz API doesn't provide copyright in album model - composer: None, // Qobuz API doesn't provide composer in track model - duration_secs: track.duration as u64, - artwork_url, - }) -} - -/// Write metadata tags to a FLAC file -pub fn write_flac_tags(file_path: &str, metadata: &CompleteTrackMetadata) -> Result<(), String> { - log::info!("Writing FLAC tags to: {}", file_path); - - let path = Path::new(file_path); - let mut tagged_file = - lofty::read_from_path(path).map_err(|e| format!("Failed to read FLAC file: {}", e))?; - - let tag = match tagged_file.primary_tag_mut() { - Some(primary_tag) => primary_tag, - None => { - let tag_type = tagged_file.primary_tag_type(); - tagged_file.insert_tag(Tag::new(tag_type)); - tagged_file.primary_tag_mut().unwrap() - } - }; - - // Clear existing tags - tag.clear(); - - // Write standard Vorbis comments - tag.set_title(metadata.title.clone()); - tag.set_artist(metadata.artist.clone()); - tag.set_album(metadata.album.clone()); - - if let Some(album_artist) = &metadata.album_artist { - tag.insert_text(ItemKey::AlbumArtist, album_artist.clone()); - } - - if let Some(track_number) = metadata.track_number { - tag.set_track(track_number); - } - - if let Some(disc_number) = metadata.disc_number { - tag.set_disk(disc_number); - } - - if let Some(year) = metadata.year { - tag.set_date(lofty::tag::items::Timestamp { - year: year as u16, - ..Default::default() - }); - } - - if let Some(genre) = &metadata.genre { - tag.set_genre(genre.clone()); - } - - if let Some(isrc) = &metadata.isrc { - tag.insert_text(ItemKey::Isrc, isrc.clone()); - } - - if let Some(label) = &metadata.label { - tag.insert_text(ItemKey::Label, label.clone()); - } - - if let Some(copyright) = &metadata.copyright { - tag.insert_text(ItemKey::CopyrightMessage, copyright.clone()); - } - - if let Some(composer) = &metadata.composer { - tag.insert_text(ItemKey::Composer, composer.clone()); - } - - // Save tags - tagged_file - .save_to_path(path, WriteOptions::default()) - .map_err(|e| format!("Failed to save tags: {}", e))?; - - Ok(()) -} - -/// Download and embed artwork in FLAC file -pub async fn embed_artwork(file_path: &str, artwork_url: &str) -> Result<(), String> { - log::info!("Embedding artwork from: {}", artwork_url); - - // Download artwork - let response = reqwest::get(artwork_url) - .await - .map_err(|e| format!("Failed to download artwork: {}", e))?; - - let artwork_bytes = response - .bytes() - .await - .map_err(|e| format!("Failed to read artwork bytes: {}", e))?; - - // Determine MIME type from URL - let mime_type = if artwork_url.contains(".jpg") || artwork_url.contains(".jpeg") { - MimeType::Jpeg - } else if artwork_url.contains(".png") { - MimeType::Png - } else { - MimeType::Jpeg // Default to JPEG - }; - - // Create picture - let picture = Picture::unchecked(artwork_bytes.to_vec()) - .pic_type(PictureType::CoverFront) - .mime_type(mime_type) - .build(); - - // Read file - let path = Path::new(file_path); - let mut tagged_file = - lofty::read_from_path(path).map_err(|e| format!("Failed to read FLAC file: {}", e))?; - - // Add picture to primary tag - if let Some(tag) = tagged_file.primary_tag_mut() { - tag.push_picture(picture); - } else { - let tag_type = tagged_file.primary_tag_type(); - let mut tag = Tag::new(tag_type); - tag.push_picture(picture); - tagged_file.insert_tag(tag); - } - - // Save - tagged_file - .save_to_path(path, WriteOptions::default()) - .map_err(|e| format!("Failed to save artwork: {}", e))?; - - Ok(()) -} - -/// Sanitize filename to be ASCII-safe and filesystem-compatible -pub fn sanitize_filename(name: &str) -> String { - // Remove or replace invalid characters - let invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']; - - let mut sanitized = name - .chars() - .map(|c| { - if invalid_chars.contains(&c) { - '-' - } else if c.is_ascii() || c.is_alphanumeric() { - c - } else { - '-' - } - }) - .collect::(); - - // Replace multiple consecutive dashes with single dash - while sanitized.contains("--") { - sanitized = sanitized.replace("--", "-"); - } - - // Trim dashes and whitespace from ends - sanitized = sanitized.trim_matches('-').trim().to_string(); - - // Limit length to 200 chars (leaving room for extension and path) - if sanitized.len() > 200 { - sanitized.truncate(200); - sanitized = sanitized.trim_matches('-').trim().to_string(); - } - - // If empty after sanitization, use fallback - if sanitized.is_empty() { - sanitized = "track".to_string(); - } - - sanitized -} - -/// Download and save album cover art as a file -pub async fn save_album_artwork(album_dir: &Path, artwork_url: &str) -> Result<(), String> { - log::info!("Downloading album artwork to: {:?}", album_dir); - - let cover_path = album_dir.join("cover.jpg"); - - // Skip if cover already exists - if cover_path.exists() { - log::debug!("Cover art already exists at {:?}", cover_path); - return Ok(()); - } - - // Download artwork - let response = reqwest::get(artwork_url) - .await - .map_err(|e| format!("Failed to download album artwork: {}", e))?; - - let artwork_bytes = response - .bytes() - .await - .map_err(|e| format!("Failed to read album artwork bytes: {}", e))?; - - // Write to file - std::fs::write(&cover_path, artwork_bytes) - .map_err(|e| format!("Failed to write cover.jpg: {}", e))?; - - log::info!("Album artwork saved to: {:?}", cover_path); - Ok(()) -} - -/// Organize cached file into proper folder structure -pub fn organize_cached_file( - track_id: u64, - temp_path: &str, - root_dir: &str, - metadata: &CompleteTrackMetadata, -) -> Result { - log::info!("Organizing cached file for track {}", track_id); - - let temp = Path::new(temp_path); - let root = Path::new(root_dir); - - // Build target path: ///[Disc N/]NN - Title.flac - let artist_dir = sanitize_filename(&metadata.album_artist.as_ref().unwrap_or(&metadata.artist)); - let album_dir = sanitize_filename(&metadata.album); - - let mut target_dir = root.join(&artist_dir).join(&album_dir); - - // Add disc subfolder if multi-disc - if let Some(disc) = metadata.disc_number { - if disc > 1 { - target_dir = target_dir.join(format!("Disc {}", disc)); - } - } - - // Create directory structure - std::fs::create_dir_all(&target_dir) - .map_err(|e| format!("Failed to create directories: {}", e))?; - - // Build filename: NN - Title.flac - let track_num = metadata.track_number.unwrap_or(0); - let title_clean = sanitize_filename(&metadata.title); - let filename = if track_num > 0 { - format!("{:02} - {}.flac", track_num, title_clean) - } else { - format!("{}.flac", title_clean) - }; - - let target_path = target_dir.join(&filename); - - // Handle filename conflicts - let final_path = if target_path.exists() { - let mut counter = 2; - loop { - let alt_filename = if track_num > 0 { - format!("{:02} - {} ({}).flac", track_num, title_clean, counter) - } else { - format!("{} ({}).flac", title_clean, counter) - }; - let alt_path = target_dir.join(&alt_filename); - if !alt_path.exists() { - break alt_path; - } - counter += 1; - if counter > 100 { - return Err("Too many filename conflicts".to_string()); - } - } - } else { - target_path - }; - - // Move file - std::fs::rename(temp, &final_path).map_err(|e| format!("Failed to move file: {}", e))?; - - Ok(final_path.to_string_lossy().to_string()) -} diff --git a/src-tauri/src/offline_cache/migration.rs b/src-tauri/src/offline_cache/migration.rs deleted file mode 100644 index de361bb99..000000000 --- a/src-tauri/src/offline_cache/migration.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! Legacy cached file migration service -//! -//! Handles migration of old numeric-named FLAC files to new organized structure - -use serde::Serialize; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::sync::{Mutex, RwLock}; - -use super::metadata::{ - embed_artwork, fetch_complete_metadata, organize_cached_file, save_album_artwork, - write_flac_tags, -}; -use crate::api::QobuzClient; -use crate::library::database::LibraryDatabase; - -#[derive(Default, Serialize, Clone, Debug)] -pub struct MigrationStatus { - pub has_legacy_files: bool, - pub total_tracks: usize, - pub processed: usize, - pub successful: usize, - pub failed: usize, - pub in_progress: bool, - pub completed: bool, - pub errors: Vec, -} - -#[derive(Serialize, Clone, Debug)] -pub struct MigrationError { - pub track_id: u64, - pub error_message: String, -} - -/// Detect legacy cached files (numeric FLAC files in tracks/ folder) -pub fn detect_legacy_cached_files(tracks_dir: &Path) -> Result, String> { - log::info!( - "Scanning for legacy cached files in: {}", - tracks_dir.display() - ); - - if !tracks_dir.exists() { - return Ok(Vec::new()); - } - - let mut track_ids = Vec::new(); - - match std::fs::read_dir(tracks_dir) { - Ok(entries) => { - for entry in entries.flatten() { - let path = entry.path(); - - // Only process FLAC files - if path.extension().and_then(|s| s.to_str()) != Some("flac") { - continue; - } - - // Check if filename is purely numeric (track_id) - if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) { - if let Ok(track_id) = filename.parse::() { - track_ids.push(track_id); - log::debug!("Found legacy track: {}", track_id); - } - } - } - } - Err(e) => { - return Err(format!("Failed to read tracks directory: {}", e)); - } - } - - log::info!("Found {} legacy cached files", track_ids.len()); - Ok(track_ids) -} - -/// Migrate a single legacy cached track -async fn migrate_single_track( - track_id: u64, - legacy_path: PathBuf, - offline_root: &str, - qobuz_client: &QobuzClient, - library_db: Arc>>, -) -> Result { - log::info!("Migrating track {}", track_id); - - // 1. Fetch complete metadata from Qobuz - let metadata = fetch_complete_metadata(track_id, qobuz_client).await?; - - // 2. Write FLAC tags - let legacy_path_str = legacy_path.to_string_lossy().to_string(); - write_flac_tags(&legacy_path_str, &metadata) - .map_err(|e| format!("Failed to write tags: {}", e))?; - - // 3. Embed artwork if available - if let Some(artwork_url) = &metadata.artwork_url { - if let Err(e) = embed_artwork(&legacy_path_str, artwork_url).await { - log::warn!("Failed to embed artwork for track {}: {}", track_id, e); - } - } - - // 4. Organize file into artist/album structure - let new_path = organize_cached_file(track_id, &legacy_path_str, offline_root, &metadata)?; - - // 5. Save album artwork as cover.jpg - let artwork_path = if let Some(artwork_url) = &metadata.artwork_url { - if let Some(parent_dir) = std::path::Path::new(&new_path).parent() { - match save_album_artwork(parent_dir, artwork_url).await { - Ok(_) => Some(parent_dir.join("cover.jpg").to_string_lossy().to_string()), - Err(e) => { - log::warn!("Failed to save album artwork for track {}: {}", track_id, e); - None - } - } - } else { - None - } - } else { - None - }; - - // 6. Extract audio properties from FLAC file - use lofty::prelude::*; - let (bit_depth, sample_rate) = match lofty::read_from_path(&new_path) { - Ok(tagged_file) => { - let properties = tagged_file.properties(); - let bit_depth = properties.bit_depth().map(|bd| bd as u32); - let sample_rate = properties.sample_rate().map(|sr| sr as f64); - (bit_depth, sample_rate) - } - Err(e) => { - log::warn!( - "Failed to read audio properties for track {}: {}", - track_id, - e - ); - (None, None) - } - }; - - // 7. Insert into local library DB - let lib_opt__ = library_db.lock().await; - let lib_guard = lib_opt__ - .as_ref() - .ok_or("No active session - please log in")?; - - let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist); - let album_group_key = format!("{}|{}", metadata.album, album_artist); - - lib_guard - .insert_qobuz_cached_track_with_grouping( - track_id, - &metadata.title, - &metadata.artist, - Some(&metadata.album), - metadata.album_artist.as_deref(), - metadata.track_number, - metadata.disc_number, - metadata.year, - metadata.duration_secs, - &new_path, - &album_group_key, - &metadata.album, - bit_depth, - sample_rate, - artwork_path.as_deref(), - ) - .map_err(|e| format!("Failed to insert to library DB: {}", e))?; - - log::info!("Track {} migrated successfully to: {}", track_id, new_path); - Ok(new_path) -} - -/// Migrate all legacy cached files -pub async fn migrate_legacy_cached_files( - track_ids: Vec, - tracks_dir: PathBuf, - offline_root: String, - qobuz_client: Arc>, - library_db: Arc>>, -) -> MigrationStatus { - let total = track_ids.len(); - let mut status = MigrationStatus { - has_legacy_files: true, - total_tracks: total, - in_progress: true, - ..Default::default() - }; - - for track_id in track_ids { - let legacy_path = tracks_dir.join(format!("{}.flac", track_id)); - - if !legacy_path.exists() { - log::warn!("Legacy file not found: {}", legacy_path.display()); - status.processed += 1; - continue; - } - - // Lock client for this migration - let client_guard = qobuz_client.read().await; - - match migrate_single_track( - track_id, - legacy_path.clone(), - &offline_root, - &*client_guard, - library_db.clone(), - ) - .await - { - Ok(_) => { - status.successful += 1; - - // Delete legacy file after successful migration - if let Err(e) = std::fs::remove_file(&legacy_path) { - log::warn!( - "Failed to delete legacy file {}: {}", - legacy_path.display(), - e - ); - } - } - Err(e) => { - status.failed += 1; - status.errors.push(MigrationError { - track_id, - error_message: e, - }); - log::error!( - "Failed to migrate track {}: {}", - track_id, - status.errors.last().unwrap().error_message - ); - } - } - - drop(client_guard); - - status.processed += 1; - } - - status.in_progress = false; - status.completed = true; - - log::info!( - "Migration complete: {}/{} successful, {} failed", - status.successful, - total, - status.failed - ); - - status -} diff --git a/src-tauri/src/offline_cache/mod.rs b/src-tauri/src/offline_cache/mod.rs index 86d2aa713..86835b800 100644 --- a/src-tauri/src/offline_cache/mod.rs +++ b/src-tauri/src/offline_cache/mod.rs @@ -1,298 +1,105 @@ -//! Offline cache module for temporary playback data -//! -//! Provides persistent disk-based caching for audio tracks: -//! - SQLite index for track metadata -//! - File-based storage for audio data -//! - LRU eviction with configurable limits -//! - Progress events for UI updates +//! Offline cache — the implementation lives in the frontend-agnostic +//! `qbz-offline-cache` crate (ADR-006). This module re-exports it and adds +//! the Tauri-specific glue: a `CacheEventSink` that re-emits the crate's +//! `CacheEvent`s as the exact legacy `offline:caching_*` / `offline:unlock_*` +//! Tauri events the Svelte frontend listens for (1:1 IPC shapes preserved). -pub mod cmaf_store; -pub mod db; -pub mod downloader; -pub mod maintenance; -pub mod metadata; -pub mod migration; -pub mod path_validator; -pub mod playback; -pub mod purge; -pub mod secret_vault; +pub use qbz_offline_cache::*; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use std::sync::{Arc, RwLock}; -use tokio::sync::{Mutex, Semaphore}; +use tauri::{Emitter, Runtime}; -pub use db::OfflineCacheDb; -pub use downloader::StreamFetcher; -pub use metadata::{sanitize_filename, CompleteTrackMetadata}; -pub use migration::{ - detect_legacy_cached_files, migrate_legacy_cached_files, MigrationError, MigrationStatus, -}; -pub use path_validator::{is_offline_root_available, validate_path, PathStatus}; - -/// 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, +/// Resolve the core Qobuz client handle from the `CoreBridge` for the +/// download pipeline. The CMAF download, metadata fetch, and legacy +/// stream-url fallback all use this single core client. Errs when no +/// session/bridge is active. +pub async fn core_client( + bridge: &crate::core_bridge::CoreBridgeState, +) -> Result< + std::sync::Arc>>, + String, +> { + let guard = bridge.0.read().await; + guard + .as_ref() + .map(|b| b.core().client()) + .ok_or_else(|| "CoreBridge not initialized".to_string()) } -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, -} - -/// Offline cache state manager -pub struct OfflineCacheState { - pub db: Arc>>, - pub fetcher: Arc, - pub cache_dir: Arc>, - /// Cache limit in bytes (None = unlimited) - pub limit_bytes: Arc>>, - pub cache_semaphore: Arc, - /// Separate library DB connection for download post-processing writes. - /// This avoids contending with the main library DB mutex used by UI queries. - pub library_db: Arc>>, -} - -impl OfflineCacheState { - /// Initialize the offline cache - pub fn new() -> Result { - let cache_dir = dirs::cache_dir() - .ok_or("Could not determine cache directory")? - .join("qbz") - .join("audio"); - - // Create directories - std::fs::create_dir_all(&cache_dir) - .map_err(|e| format!("Failed to create cache directory: {}", e))?; - std::fs::create_dir_all(cache_dir.join("tracks")) - .map_err(|e| format!("Failed to create tracks directory: {}", e))?; - std::fs::create_dir_all(cache_dir.join("artwork")) - .map_err(|e| format!("Failed to create artwork directory: {}", e))?; - - let db_path = cache_dir.join("index.db"); - let db = OfflineCacheDb::new(&db_path)?; - - // Default limit: 5GB - let default_limit = Some(5 * 1024 * 1024 * 1024u64); - - let state = Self { - db: Arc::new(Mutex::new(Some(db))), - fetcher: Arc::new(StreamFetcher::new()), - cache_dir: Arc::new(RwLock::new(cache_dir.clone())), - limit_bytes: Arc::new(Mutex::new(default_limit)), - cache_semaphore: Arc::new(Semaphore::new(3)), - library_db: Arc::new(Mutex::new(None)), - }; - - log::info!("Offline cache initialized at: {:?}", cache_dir); - - Ok(state) - } - - pub fn new_empty() -> Self { - let cache_dir = dirs::cache_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("qbz") - .join("audio"); - Self { - db: Arc::new(Mutex::new(None)), - fetcher: Arc::new(StreamFetcher::new()), - cache_dir: Arc::new(RwLock::new(cache_dir)), - limit_bytes: Arc::new(Mutex::new(Some(5 * 1024 * 1024 * 1024u64))), - cache_semaphore: Arc::new(Semaphore::new(3)), - library_db: Arc::new(Mutex::new(None)), - } - } - - pub async fn init_at(&self, cache_base_dir: &std::path::Path) -> Result<(), String> { - let cache_dir = cache_base_dir.join("audio"); - std::fs::create_dir_all(&cache_dir) - .map_err(|e| format!("Failed to create cache directory: {}", e))?; - std::fs::create_dir_all(cache_dir.join("tracks")) - .map_err(|e| format!("Failed to create tracks directory: {}", e))?; - std::fs::create_dir_all(cache_dir.join("artwork")) - .map_err(|e| format!("Failed to create artwork directory: {}", e))?; - let db_path = cache_dir.join("index.db"); - let new_db = OfflineCacheDb::new(&db_path)?; - let mut guard = self.db.lock().await; - *guard = Some(new_db); - // Update cache_dir to user-scoped path - if let Ok(mut dir_guard) = self.cache_dir.write() { - *dir_guard = cache_dir.clone(); - } - log::info!("Offline cache initialized at: {:?}", cache_dir); - Ok(()) - } - - /// Open a separate library DB connection for download post-processing. - /// Must be called after library.init_at() so the schema exists. - pub async fn init_library_connection(&self, data_dir: &std::path::Path) -> Result<(), String> { - let db_path = data_dir.join("library.db"); - let lib_db = qbz_library::LibraryDatabase::open(&db_path) - .map_err(|e| format!("Failed to open download library connection: {}", e))?; - let mut guard = self.library_db.lock().await; - *guard = Some(lib_db); - log::info!( - "Offline cache: separate library DB connection opened at {:?}", - db_path - ); - Ok(()) - } - - pub async fn teardown(&self) { - // Close library connection first (before main teardown) - { - let mut lib_guard = self.library_db.lock().await; - *lib_guard = None; - } - let mut guard = self.db.lock().await; - *guard = None; - } - - /// Get the path for a track's audio file - pub fn track_file_path(&self, track_id: u64, format: &str) -> PathBuf { - let dir = self.cache_dir.read().unwrap(); - dir.join("tracks").join(format!("{}.{}", track_id, format)) - } - - /// Get the path for an album's artwork - pub fn artwork_path(&self, album_id: &str) -> PathBuf { - let dir = self.cache_dir.read().unwrap(); - dir.join("artwork").join(format!("{}.jpg", album_id)) - } - - /// Get the cache directory path - pub fn get_cache_path(&self) -> String { - let dir = self.cache_dir.read().unwrap(); - dir.to_string_lossy().to_string() - } - - /// Seed the in-memory `limit_bytes` from the persisted offline_settings DB. - /// - /// Called at session activation (after `init_at`) so the user's previously - /// chosen limit survives across restarts. When the persisted value is - /// missing (legacy installs with NULL column), the default 5 GB seeded by - /// `init_at`/`new` is preserved. - pub async fn apply_persisted_limit( - &self, - offline_state: &crate::offline::OfflineState, - ) -> Result<(), String> { - let persisted: Option = { - let guard = offline_state - .store - .lock() - .map_err(|e| format!("Lock error: {}", e))?; - match guard.as_ref() { - Some(store) => store.get_cache_limit_bytes()?, - None => return Ok(()), - } - }; - - if let Some(bytes) = persisted { - let mut limit = self.limit_bytes.lock().await; - *limit = Some(bytes); - log::info!( - "Offline cache: applied persisted size limit ({} bytes)", - bytes - ); - } else { - log::info!( - "Offline cache: no persisted size limit, keeping in-memory default" +/// Build a [`qbz_offline_cache::CacheEventSink`] that re-emits the crate's +/// `CacheEvent`s as the legacy Tauri events. Cheap to build per call (an +/// `Arc`'d closure capturing the `AppHandle`). +pub fn tauri_cache_sink( + app: tauri::AppHandle, +) -> qbz_offline_cache::CacheEventSink { + use qbz_offline_cache::{CacheEvent, CacheFormat}; + std::sync::Arc::new(move |ev: CacheEvent| match ev { + CacheEvent::Started { track_id } => { + let _ = app.emit( + "offline:caching_started", + serde_json::json!({ "trackId": track_id }), ); } - Ok(()) - } + CacheEvent::Progress { + track_id, + progress_percent, + bytes_downloaded, + total_bytes, + } => { + let _ = app.emit( + "offline:caching_progress", + serde_json::json!({ + "trackId": track_id, + "progressPercent": progress_percent, + "bytesDownloaded": bytes_downloaded, + "totalBytes": total_bytes, + "status": "downloading", + }), + ); + } + CacheEvent::Completed { + track_id, + size, + format, + } => { + // Legacy path omitted `format`; CMAF path set "cmaf". Preserve both. + let payload = match format { + CacheFormat::Cmaf => { + serde_json::json!({ "trackId": track_id, "size": size, "format": "cmaf" }) + } + CacheFormat::Flac => serde_json::json!({ "trackId": track_id, "size": size }), + }; + let _ = app.emit("offline:caching_completed", payload); + } + CacheEvent::Processed { + track_id, + path, + format, + } => { + let payload = match format { + CacheFormat::Cmaf => { + serde_json::json!({ "trackId": track_id, "path": path, "format": "cmaf" }) + } + CacheFormat::Flac => serde_json::json!({ "trackId": track_id, "path": path }), + }; + let _ = app.emit("offline:caching_processed", payload); + } + CacheEvent::Failed { track_id, error } => { + let _ = app.emit( + "offline:caching_failed", + serde_json::json!({ "trackId": track_id, "error": error }), + ); + } + CacheEvent::UnlockStart { track_id } => { + let _ = app.emit( + "offline:unlock_start", + serde_json::json!({ "trackId": track_id }), + ); + } + CacheEvent::UnlockEnd { track_id, success } => { + let _ = app.emit( + "offline:unlock_end", + serde_json::json!({ "trackId": track_id, "success": success }), + ); + } + }) } diff --git a/src-tauri/src/offline_cache/path_validator.rs b/src-tauri/src/offline_cache/path_validator.rs deleted file mode 100644 index 667cd2f6c..000000000 --- a/src-tauri/src/offline_cache/path_validator.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! 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/src-tauri/src/offline_cache/playback.rs b/src-tauri/src/offline_cache/playback.rs deleted file mode 100644 index 57c9575f5..000000000 --- a/src-tauri/src/offline_cache/playback.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! Offline-cache → audio-bytes bridge for playback. -//! -//! When a track is played back from the offline cache (either via the -//! main playback path or via the Local Library path when the track is a -//! Qobuz-cached offline entry), this module converts the stored row -//! into a `Vec` ready for `player.play_data`. -//! -//! For `cache_format = 2` (v2 CMAF bundle) this means: -//! 1. Read init.mp4 + segments.bin + manifest.json from disk -//! 2. Unwrap the content_key via the secret vault -//! 3. Decrypt the encrypted frames and prepend the FLAC header -//! -//! For `cache_format = 1` (legacy plain FLAC) the caller should just -//! `std::fs::read(file_path)` directly — this module doesn't handle v1 -//! since v1 needs no extra work. - -use std::path::Path; - -use tauri::Emitter; - -use super::cmaf_store::{self, BundleLayout}; -use super::db::CmafBundleRow; -use super::secret_vault; - -/// Run `load_cmaf_bundle` on the blocking pool and emit -/// `offline:unlock_start` / `offline:unlock_end` around it so the -/// frontend can show an "unlocking" animation on the track row. -/// -/// `display_track_id` is what the frontend knows this track as — for -/// Qobuz flow it's the Qobuz track id, for Local Library it's the -/// library row id. The events carry THIS id so whatever UI is looking -/// at the track can key off it. -/// -/// `cmaf_track_id` is the key used inside load_cmaf_bundle for logs -/// (always the Qobuz track id, since that's what the bundle is -/// identified by on disk + in the offline cache DB). -pub async fn load_cmaf_bundle_with_ui_events( - app: &tauri::AppHandle, - display_track_id: u64, - cmaf_track_id: u64, - row: CmafBundleRow, - cache_path: String, -) -> Option> { - let _ = app.emit( - "offline:unlock_start", - serde_json::json!({ "trackId": display_track_id }), - ); - let result = tokio::task::spawn_blocking(move || { - load_cmaf_bundle(cmaf_track_id, &row, Path::new(&cache_path)) - }) - .await - .ok() - .flatten(); - let _ = app.emit( - "offline:unlock_end", - serde_json::json!({ - "trackId": display_track_id, - "success": result.is_some(), - }), - ); - result -} - -/// Decrypt a v2 CMAF bundle row into plain FLAC bytes ready for -/// `player.play_data`. Returns `None` on any failure (missing init, -/// wrong-size unwrapped key, corrupt manifest, decrypt error). The -/// caller should treat `None` as a cache miss — continue to the next -/// tier or the network. -/// -/// `offline_root_path` is only used to locate the secret vault's -/// install UUID file; it must match the path used at download time. -/// Passing `OfflineCacheState::get_cache_path()` is correct. -pub fn load_cmaf_bundle( - track_id: u64, - row: &CmafBundleRow, - offline_root_path: &Path, -) -> Option> { - if row.cache_format != 2 { - return None; - } - - let init_path = row.init_path.as_ref().or_else(|| { - log::warn!( - "[OfflineCache/Play] Track {} cache_format=2 but init_path is null", - track_id - ); - None - })?; - let content_key_wrapped = row.content_key_wrapped.as_ref().or_else(|| { - log::warn!( - "[OfflineCache/Play] Track {} cache_format=2 but content_key_wrapped is null", - track_id - ); - None - })?; - - let segments_path = std::path::PathBuf::from(&row.segments_path); - let track_dir = segments_path.parent()?.to_path_buf(); - let layout = BundleLayout { - track_dir, - init_path: std::path::PathBuf::from(init_path), - segments_path: segments_path.clone(), - manifest_path: segments_path.with_file_name("manifest.json"), - }; - - let loaded = match cmaf_store::read_bundle(&layout) { - Ok(lb) => lb, - Err(e) => { - log::warn!( - "[OfflineCache/Play] Track {} failed to read CMAF bundle: {}", - track_id, - e - ); - return None; - } - }; - - let vault = match secret_vault::get_or_init(offline_root_path) { - Ok(v) => v, - Err(e) => { - log::warn!( - "[OfflineCache/Play] Track {} SecretBox init failed: {}", - track_id, - e - ); - return None; - } - }; - let unwrapped = match vault.unwrap(content_key_wrapped) { - Ok(k) => k, - Err(e) => { - log::warn!( - "[OfflineCache/Play] Track {} content_key unwrap failed: {}", - track_id, - e - ); - return None; - } - }; - if unwrapped.len() != 16 { - log::warn!( - "[OfflineCache/Play] Track {} unwrapped content_key wrong size ({} bytes)", - track_id, - unwrapped.len() - ); - return None; - } - let mut content_key = [0u8; 16]; - content_key.copy_from_slice(&unwrapped); - - match loaded.decrypt_to_flac(&content_key) { - Ok(flac_bytes) => { - log::info!( - "[OfflineCache/Play] Track {} unwrapped + decrypted ({:.2} MB FLAC)", - track_id, - flac_bytes.len() as f64 / (1024.0 * 1024.0) - ); - Some(flac_bytes) - } - Err(e) => { - log::warn!( - "[OfflineCache/Play] Track {} CMAF decrypt failed: {}", - track_id, - e - ); - None - } - } -} diff --git a/src-tauri/src/offline_cache/purge.rs b/src-tauri/src/offline_cache/purge.rs deleted file mode 100644 index b3f4f2584..000000000 --- a/src-tauri/src/offline_cache/purge.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! Offline cache purge helper. -//! -//! Used by `session_lifecycle` to clear all cached audio when the active -//! session is torn down. Not a Tauri command — pure helper. - -use crate::library::LibraryState; -use crate::offline_cache::OfflineCacheState; - -/// Clear entire offline cache (internal helper) -pub async fn purge_all_cached_files( - cache_state: &OfflineCacheState, - library_state: &LibraryState, -) -> Result<(), String> { - let paths = { - let guard__ = cache_state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - db.clear_all()? - }; - - // Delete all files. For v1 entries `file_path` is the plain FLAC; - // for v2 entries it's `/segments.bin` — we remove the enclosing - // track directory so init.mp4 + manifest.json + segments.bin all go - // in one step. Plain files still work because remove_dir_all on a - // file path fails silently and we fall through to remove_file. - for path in paths { - let p = std::path::Path::new(&path); - if !p.exists() { - continue; - } - // Heuristic: the v2 layout puts everything inside `tracks-cmaf//`. - // If the parent directory matches that shape, remove the directory. - let looks_like_v2 = p - .parent() - .and_then(|parent| parent.parent()) - .and_then(|root| root.file_name()) - .and_then(|n| n.to_str()) - == Some("tracks-cmaf"); - if looks_like_v2 { - if let Some(track_dir) = p.parent() { - let _ = std::fs::remove_dir_all(track_dir); - continue; - } - } - let _ = std::fs::remove_file(p); - } - - // Also clear the tracks directory (legacy unorganized files) - let cache_dir = cache_state.cache_dir.read().unwrap().clone(); - let tracks_dir = cache_dir.join("tracks"); - if tracks_dir.exists() { - if let Ok(entries) = std::fs::read_dir(&tracks_dir) { - for entry in entries.flatten() { - let _ = std::fs::remove_file(entry.path()); - } - } - } - - // And the tracks-cmaf directory (v2 bundles) — belt-and-suspenders - // so orphan bundles from corrupt DB rows get cleaned up too. - let tracks_cmaf_dir = cache_dir.join("tracks-cmaf"); - if tracks_cmaf_dir.exists() { - let _ = std::fs::remove_dir_all(&tracks_cmaf_dir); - log::info!("[OfflineCache/Purge] Removed tracks-cmaf/ directory"); - } - - // Clear organized artist/album folders - // Look for any subdirectories in cache_dir that are not "tracks" or system files - if let Ok(entries) = std::fs::read_dir(&cache_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - // Skip the tracks directory and database files - if name != "tracks" && !name.ends_with(".db") && !name.ends_with(".db-journal") { - // This is likely an artist folder, delete it recursively - if let Err(e) = std::fs::remove_dir_all(&path) { - log::warn!("Failed to remove folder {:?}: {}", path, e); - } else { - log::info!("Removed artist folder: {:?}", path); - } - } - } - } - } - - // Remove all Qobuz cached tracks from library - let guard__ = library_state.db.lock().await; - let library_db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - let removed_count = library_db - .remove_all_qobuz_cached_tracks() - .map_err(|e| format!("Failed to remove cached tracks from library: {}", e))?; - log::info!("Removed {} Qobuz cached tracks from library", removed_count); - - Ok(()) -} diff --git a/src-tauri/src/offline_cache/secret_vault.rs b/src-tauri/src/offline_cache/secret_vault.rs deleted file mode 100644 index 8a920db3a..000000000 --- a/src-tauri/src/offline_cache/secret_vault.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! 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/src-tauri/src/session_lifecycle.rs b/src-tauri/src/session_lifecycle.rs index 63b8b9812..60550d7e4 100644 --- a/src-tauri/src/session_lifecycle.rs +++ b/src-tauri/src/session_lifecycle.rs @@ -217,12 +217,12 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<() // both `offline.init_at` (above) and `offline_cache.init_at` so the DB // handle is available. Falls back silently to the 5 GB default when // unset. - if let Err(e) = offline_cache.apply_persisted_limit(&offline).await { - log::warn!( - "[SessionLifecycle] Failed to apply persisted offline cache limit: {} (using default)", - e - ); - } + let persisted_limit = offline.store.lock().ok().and_then(|guard| { + guard + .as_ref() + .and_then(|s| s.get_cache_limit_bytes().ok().flatten()) + }); + offline_cache.apply_persisted_limit(persisted_limit).await; lyrics.init_at(&cache_dir).await?; // Run deferred subscription purge check @@ -245,7 +245,7 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<() log::warn!("[SessionLifecycle] Subscription invalid beyond the grace window. Purging offline cache."); if let Err(e) = crate::offline_cache::purge::purge_all_cached_files( offline_cache.inner(), - library.inner(), + &library.inner().db, ) .await {