From aab3e6764134d6ee36647c412ee477e7aac62f7c Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:11:07 -0600 Subject: [PATCH] add per-user isolation support to all remaining modules Add new_at() constructors, Option wrappers, new_empty()/init_at()/ teardown() methods, and update all commands to unwrap Option for: - config: audio_settings, download_settings, favorites_cache, favorites_preferences, legal_settings, playback_preferences, remote_control_settings, subscription_state, tray_settings - data: api_cache, artist_blacklist, artist_vectors, library, listenbrainz, lyrics, musicbrainz, offline, offline_cache, radio_engine, reco_store, updates --- src-tauri/src/api_cache/mod.rs | 27 ++- src-tauri/src/artist_blacklist/mod.rs | 71 ++++-- src-tauri/src/artist_vectors/builder.rs | 29 ++- src-tauri/src/artist_vectors/store.rs | 27 ++- src-tauri/src/artist_vectors/suggestions.rs | 22 +- src-tauri/src/config/audio_settings.rs | 86 ++++++-- src-tauri/src/config/download_settings.rs | 45 ++-- src-tauri/src/config/favorites_cache.rs | 86 ++++++-- src-tauri/src/config/favorites_preferences.rs | 72 ++++-- src-tauri/src/config/legal_settings.rs | 42 +++- src-tauri/src/config/playback_preferences.rs | 59 ++++- .../src/config/remote_control_settings.rs | 162 ++++++++++---- src-tauri/src/config/subscription_state.rs | 29 ++- src-tauri/src/config/tray_settings.rs | 63 ++++-- src-tauri/src/library/mod.rs | 27 ++- src-tauri/src/listenbrainz/cache.rs | 27 ++- src-tauri/src/listenbrainz/mod.rs | 56 ++++- src-tauri/src/lyrics/commands.rs | 15 +- src-tauri/src/lyrics/mod.rs | 27 ++- src-tauri/src/musicbrainz/cache.rs | 27 ++- src-tauri/src/musicbrainz/mod.rs | 27 ++- src-tauri/src/offline/mod.rs | 94 ++++++-- src-tauri/src/offline_cache/commands.rs | 206 ++++++++++-------- src-tauri/src/offline_cache/migration.rs | 7 +- src-tauri/src/offline_cache/mod.rs | 39 +++- src-tauri/src/radio_engine/db.rs | 8 + src-tauri/src/reco_store/commands.rs | 113 +++++----- src-tauri/src/reco_store/mod.rs | 27 ++- src-tauri/src/updates/mod.rs | 83 +++++-- 29 files changed, 1174 insertions(+), 429 deletions(-) diff --git a/src-tauri/src/api_cache/mod.rs b/src-tauri/src/api_cache/mod.rs index 5d9562718..2cecf89f8 100644 --- a/src-tauri/src/api_cache/mod.rs +++ b/src-tauri/src/api_cache/mod.rs @@ -11,7 +11,7 @@ use tokio::sync::Mutex; /// API cache state shared across commands pub struct ApiCacheState { - pub cache: Arc>, + pub cache: Arc>>, } impl ApiCacheState { @@ -30,9 +30,32 @@ impl ApiCacheState { log::info!("API cache initialized at {:?}", db_path); Ok(Self { - cache: Arc::new(Mutex::new(cache)), + cache: Arc::new(Mutex::new(Some(cache))), }) } + + pub fn new_empty() -> Self { + Self { + cache: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let cache_dir = base_dir.join("cache"); + std::fs::create_dir_all(&cache_dir) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + let db_path = cache_dir.join("api_cache.db"); + let new_cache = ApiCache::new(&db_path)?; + log::info!("API cache initialized at {:?}", db_path); + let mut guard = self.cache.blocking_lock(); + *guard = Some(new_cache); + Ok(()) + } + + pub fn teardown(&self) { + let mut guard = self.cache.blocking_lock(); + *guard = None; + } } /// Default TTL for cached items (24 hours) diff --git a/src-tauri/src/artist_blacklist/mod.rs b/src-tauri/src/artist_blacklist/mod.rs index 0908daf2c..f9baf5b82 100644 --- a/src-tauri/src/artist_blacklist/mod.rs +++ b/src-tauri/src/artist_blacklist/mod.rs @@ -24,11 +24,12 @@ pub mod service; pub use models::{BlacklistSettings, BlacklistedArtist}; pub use service::BlacklistService; +use std::path::Path; use std::sync::Mutex; /// Thread-safe state wrapper for the blacklist service pub struct BlacklistState { - pub service: Mutex, + pub service: Mutex>, } impl BlacklistState { @@ -45,16 +46,39 @@ impl BlacklistState { let service = BlacklistService::new(&db_path)?; Ok(Self { - service: Mutex::new(service), + service: Mutex::new(Some(service)), }) } + pub fn new_empty() -> Self { + Self { + service: Mutex::new(None), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + std::fs::create_dir_all(base_dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + let db_path = base_dir.join("artist_blacklist.db"); + let new_service = BlacklistService::new(&db_path)?; + let mut guard = self.service.lock().map_err(|_| "Failed to acquire lock")?; + *guard = Some(new_service); + Ok(()) + } + + pub fn teardown(&self) { + if let Ok(mut guard) = self.service.lock() { + *guard = None; + } + } + /// Check if an artist is blacklisted - thread-safe O(1) operation #[inline] pub fn is_blacklisted(&self, artist_id: u64) -> bool { self.service .lock() - .map(|s| s.is_blacklisted(artist_id)) + .ok() + .and_then(|guard| guard.as_ref().map(|s| s.is_blacklisted(artist_id))) .unwrap_or(false) } @@ -63,60 +87,59 @@ impl BlacklistState { pub fn is_enabled(&self) -> bool { self.service .lock() - .map(|s| s.is_enabled()) + .ok() + .and_then(|guard| guard.as_ref().map(|s| s.is_enabled())) .unwrap_or(true) } /// Add an artist to the blacklist pub fn add(&self, artist_id: u64, artist_name: &str, notes: Option<&str>) -> Result<(), String> { - self.service - .lock() - .map_err(|_| "Failed to acquire lock")? + let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?; + guard.as_ref().ok_or("No active session - please log in")? .add(artist_id, artist_name, notes) } /// Remove an artist from the blacklist pub fn remove(&self, artist_id: u64) -> Result<(), String> { - self.service - .lock() - .map_err(|_| "Failed to acquire lock")? + let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?; + guard.as_ref().ok_or("No active session - please log in")? .remove(artist_id) } /// Get all blacklisted artists pub fn get_all(&self) -> Result, String> { - self.service - .lock() - .map_err(|_| "Failed to acquire lock")? + let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?; + guard.as_ref().ok_or("No active session - please log in")? .get_all() } /// Set enabled state pub fn set_enabled(&self, enabled: bool) -> Result<(), String> { - self.service - .lock() - .map_err(|_| "Failed to acquire lock")? + let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?; + guard.as_ref().ok_or("No active session - please log in")? .set_enabled(enabled) } /// Get blacklist settings pub fn get_settings(&self) -> Result { - self.service - .lock() - .map(|s| s.get_settings()) - .map_err(|_| "Failed to acquire lock".to_string()) + let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?; + Ok(guard.as_ref().ok_or("No active session - please log in")? + .get_settings()) } /// Get count of blacklisted artists pub fn count(&self) -> usize { - self.service.lock().map(|s| s.count()).unwrap_or(0) + self.service + .lock() + .ok() + .and_then(|guard| guard.as_ref().map(|s| s.count())) + .unwrap_or(0) } /// Clear all blacklisted artists pub fn clear_all(&self) -> Result<(), String> { - self.service - .lock() - .map_err(|_| "Failed to acquire lock")? + let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?; + guard.as_ref().ok_or("No active session - please log in")? .clear_all() } } diff --git a/src-tauri/src/artist_vectors/builder.rs b/src-tauri/src/artist_vectors/builder.rs index 6c719795f..6d38e3183 100644 --- a/src-tauri/src/artist_vectors/builder.rs +++ b/src-tauri/src/artist_vectors/builder.rs @@ -19,11 +19,11 @@ use super::weights::RelationshipWeights; /// Builder for constructing artist vectors from multiple data sources pub struct ArtistVectorBuilder { /// Vector store for persistence - store: Arc>, + store: Arc>>, /// MusicBrainz client for relationship data mb_client: Arc, /// MusicBrainz cache - mb_cache: Arc>, + mb_cache: Arc>>, /// Qobuz client for similar artists qobuz_client: Arc>, /// Configurable weights @@ -46,9 +46,9 @@ pub struct BuildResult { impl ArtistVectorBuilder { /// Create a new builder with the given dependencies pub fn new( - store: Arc>, + store: Arc>>, mb_client: Arc, - mb_cache: Arc>, + mb_cache: Arc>>, qobuz_client: Arc>, weights: RelationshipWeights, ) -> Self { @@ -88,7 +88,8 @@ impl ArtistVectorBuilder { // 1. Get or create index for this artist log::info!("[VectorBuilder] Acquiring store lock..."); { - let mut store = self.store.lock().await; + let mut guard__ = self.store.lock().await; + let store = guard__.as_mut().ok_or("No active session - please log in")?; log::info!("[VectorBuilder] Store lock acquired, creating index"); store.get_or_create_idx(artist_mbid, artist_name)?; } @@ -130,7 +131,8 @@ impl ArtistVectorBuilder { // 4. Persist the vectors (using saved vectors to avoid deadlock) log::info!("[VectorBuilder] Persisting vectors..."); { - let mut store = self.store.lock().await; + let mut guard__ = self.store.lock().await; + let store = guard__.as_mut().ok_or("No active session - please log in")?; // Store MB relationships if let Some(mb_vec) = mb_vec_to_store { @@ -161,7 +163,8 @@ impl ArtistVectorBuilder { // Try cache first log::info!("[VectorBuilder] Acquiring mb_cache lock..."); let cached = { - let cache = self.mb_cache.lock().await; + let guard__ = self.mb_cache.lock().await; + let cache = guard__.as_ref().ok_or("No active session - please log in")?; log::info!("[VectorBuilder] mb_cache lock acquired, checking relations"); cache.get_artist_relations(artist_mbid)? }; @@ -181,7 +184,8 @@ impl ArtistVectorBuilder { // Cache it { - let cache = self.mb_cache.lock().await; + let guard__ = self.mb_cache.lock().await; + let cache = guard__.as_ref().ok_or("No active session - please log in")?; cache.set_artist_relations(artist_mbid, &extracted)?; } @@ -192,7 +196,8 @@ impl ArtistVectorBuilder { let mut count = 0; // Get store for index lookups - let mut store = self.store.lock().await; + let mut guard__ = self.store.lock().await; + let store = guard__.as_mut().ok_or("No active session - please log in")?; // Process members (band → person) for member in &relations.members { @@ -241,7 +246,8 @@ impl ArtistVectorBuilder { let mut vector = SparseVector::new(); let mut count = 0; - let mut store = self.store.lock().await; + let mut guard__ = self.store.lock().await; + let store = guard__.as_mut().ok_or("No active session - please log in")?; for artist in similar.items { // We need to resolve Qobuz artist to MBID @@ -271,7 +277,8 @@ impl ArtistVectorBuilder { // Check if we have a fresh vector let has_fresh = { - let store = self.store.lock().await; + let guard__ = self.store.lock().await; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.has_fresh_vector(artist_mbid, max_age_secs) }; diff --git a/src-tauri/src/artist_vectors/store.rs b/src-tauri/src/artist_vectors/store.rs index 53b2c87ef..f31427148 100644 --- a/src-tauri/src/artist_vectors/store.rs +++ b/src-tauri/src/artist_vectors/store.rs @@ -17,7 +17,7 @@ const VECTOR_TTL_SECS: i64 = 7 * 24 * 60 * 60; /// Shared state wrapper for Tauri pub struct ArtistVectorStoreState { - pub store: Arc>, + pub store: Arc>>, } impl ArtistVectorStoreState { @@ -36,9 +36,32 @@ impl ArtistVectorStoreState { log::info!("Artist vector store initialized at {:?}", db_path); Ok(Self { - store: Arc::new(Mutex::new(store)), + store: Arc::new(Mutex::new(Some(store))), }) } + + pub fn new_empty() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let cache_dir = base_dir.join("cache"); + std::fs::create_dir_all(&cache_dir) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + let db_path = cache_dir.join("artist_vectors.db"); + let new_store = ArtistVectorStore::new(&db_path)?; + log::info!("Artist vector store initialized at {:?}", db_path); + let mut guard = self.store.blocking_lock(); + *guard = Some(new_store); + Ok(()) + } + + pub fn teardown(&self) { + let mut guard = self.store.blocking_lock(); + *guard = None; + } } /// Artist vector store with SQLite backend diff --git a/src-tauri/src/artist_vectors/suggestions.rs b/src-tauri/src/artist_vectors/suggestions.rs index 7dfbd779f..9b33698a2 100644 --- a/src-tauri/src/artist_vectors/suggestions.rs +++ b/src-tauri/src/artist_vectors/suggestions.rs @@ -94,7 +94,7 @@ pub struct SuggestionResult { /// Playlist suggestions engine pub struct SuggestionsEngine { /// Vector store for similarity lookups - store: Arc>, + store: Arc>>, /// Vector builder for lazy construction builder: Arc, /// Qobuz client for track search @@ -106,7 +106,7 @@ pub struct SuggestionsEngine { impl SuggestionsEngine { /// Create a new suggestions engine pub fn new( - store: Arc>, + store: Arc>>, builder: Arc, qobuz_client: Arc>, config: SuggestionConfig, @@ -184,7 +184,8 @@ impl SuggestionsEngine { let step3_start = Instant::now(); let exclude_vec: Vec = playlist_artist_mbids.to_vec(); let similar_artists = { - let store = self.store.lock().await; + let guard__ = self.store.lock().await; + let store = guard__.as_ref().ok_or("No active session - please log in")?; // Use direct relationship lookup instead of vector similarity // This finds members, collaborators, etc. from the MusicBrainz data store.get_all_related_artists(&playlist_artist_mbids, &exclude_vec, self.config.max_artists)? @@ -396,7 +397,8 @@ impl SuggestionsEngine { artist_mbids: &[String], ) -> Result { let mut combined = SparseVector::new(); - let store = self.store.lock().await; + let guard__ = self.store.lock().await; + let store = guard__.as_ref().ok_or("No active session - please log in")?; for mbid in artist_mbids { if let Some(vector) = store.get_vector(mbid) { @@ -506,10 +508,14 @@ impl SuggestionsEngine { Some(name) => name.to_string(), None => { // Try to get name from store - let store = self.store.lock().await; - store - .get_artist_name(artist_mbid) - .unwrap_or_else(|| artist_mbid.to_string()) + let guard__ = self.store.lock().await; + if let Some(store) = guard__.as_ref() { + store + .get_artist_name(artist_mbid) + .unwrap_or_else(|| artist_mbid.to_string()) + } else { + artist_mbid.to_string() + } } }; diff --git a/src-tauri/src/config/audio_settings.rs b/src-tauri/src/config/audio_settings.rs index c5bf49446..458a92e8e 100644 --- a/src-tauri/src/config/audio_settings.rs +++ b/src-tauri/src/config/audio_settings.rs @@ -5,6 +5,7 @@ use crate::audio::{AlsaPlugin, AudioBackendType}; use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::sync::{Arc, Mutex}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -55,15 +56,11 @@ pub struct AudioSettingsStore { } impl AudioSettingsStore { - pub fn new() -> Result { - let data_dir = dirs::data_dir() - .ok_or("Could not determine data directory")? - .join("qbz"); - - std::fs::create_dir_all(&data_dir) + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; - let db_path = data_dir.join("audio_settings.db"); + let db_path = dir.join(db_name); let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open audio settings database: {}", e))?; @@ -97,6 +94,17 @@ impl AudioSettingsStore { Ok(Self { conn }) } + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "audio_settings.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "audio_settings.db") + } + pub fn get_settings(&self) -> Result { self.conn .query_row( @@ -267,15 +275,36 @@ impl AudioSettingsStore { /// Thread-safe wrapper pub struct AudioSettingsState { - pub store: Arc>, + pub store: Arc>>, } impl AudioSettingsState { pub fn new() -> Result { Ok(Self { - store: Arc::new(Mutex::new(AudioSettingsStore::new()?)), + store: Arc::new(Mutex::new(Some(AudioSettingsStore::new()?))), }) } + + pub fn new_empty() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let new_store = AudioSettingsStore::new_at(base_dir)?; + let mut guard = self.store.lock() + .map_err(|_| "Failed to lock audio settings store".to_string())?; + *guard = Some(new_store); + Ok(()) + } + + pub fn teardown(&self) -> Result<(), String> { + let mut guard = self.store.lock() + .map_err(|_| "Failed to lock audio settings store".to_string())?; + *guard = None; + Ok(()) + } } // Tauri commands @@ -283,7 +312,8 @@ impl AudioSettingsState { pub fn get_audio_settings( state: tauri::State<'_, AudioSettingsState>, ) -> Result { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.get_settings() } @@ -304,7 +334,8 @@ pub fn set_audio_output_device( normalized_device ); - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_output_device(normalized_device.as_deref()) } @@ -313,7 +344,8 @@ pub fn set_audio_exclusive_mode( state: tauri::State<'_, AudioSettingsState>, enabled: bool, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_exclusive_mode(enabled) } @@ -322,7 +354,8 @@ pub fn set_audio_dac_passthrough( state: tauri::State<'_, AudioSettingsState>, enabled: bool, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_dac_passthrough(enabled) } @@ -331,7 +364,8 @@ pub fn set_audio_sample_rate( state: tauri::State<'_, AudioSettingsState>, rate: Option, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_sample_rate(rate) } @@ -340,7 +374,8 @@ pub fn set_audio_backend_type( state: tauri::State<'_, AudioSettingsState>, backend_type: Option, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_backend_type(backend_type) } @@ -349,7 +384,8 @@ pub fn set_audio_alsa_plugin( state: tauri::State<'_, AudioSettingsState>, plugin: Option, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_alsa_plugin(plugin) } @@ -358,7 +394,8 @@ pub fn set_audio_alsa_hardware_volume( state: tauri::State<'_, AudioSettingsState>, enabled: bool, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_alsa_hardware_volume(enabled) } @@ -367,7 +404,8 @@ pub fn set_audio_stream_first_track( state: tauri::State<'_, AudioSettingsState>, enabled: bool, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_stream_first_track(enabled) } @@ -376,7 +414,8 @@ pub fn set_audio_stream_buffer_seconds( state: tauri::State<'_, AudioSettingsState>, seconds: u8, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_stream_buffer_seconds(seconds) } @@ -385,7 +424,8 @@ pub fn set_audio_streaming_only( state: tauri::State<'_, AudioSettingsState>, enabled: bool, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_streaming_only(enabled) } @@ -394,7 +434,8 @@ pub fn set_audio_limit_quality_to_device( state: tauri::State<'_, AudioSettingsState>, enabled: bool, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_limit_quality_to_device(enabled) } @@ -403,6 +444,7 @@ pub fn set_audio_device_max_sample_rate( state: tauri::State<'_, AudioSettingsState>, rate: Option, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_device_max_sample_rate(rate) } diff --git a/src-tauri/src/config/download_settings.rs b/src-tauri/src/config/download_settings.rs index dcf255924..fcce72e17 100644 --- a/src-tauri/src/config/download_settings.rs +++ b/src-tauri/src/config/download_settings.rs @@ -4,6 +4,7 @@ use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::sync::{Arc, Mutex}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -33,15 +34,11 @@ pub struct DownloadSettingsStore { } impl DownloadSettingsStore { - pub fn new() -> Result { - let data_dir = dirs::data_dir() - .ok_or("Could not determine data directory")? - .join("qbz"); - - std::fs::create_dir_all(&data_dir) + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; - let db_path = data_dir.join("download_settings.db"); + let db_path = dir.join(db_name); let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open download settings database: {}", e))?; @@ -64,6 +61,17 @@ impl DownloadSettingsStore { Ok(Self { conn }) } + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "download_settings.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "download_settings.db") + } + pub fn get_settings(&self) -> Result { self.conn .query_row( @@ -100,11 +108,15 @@ impl DownloadSettingsStore { } } -pub type DownloadSettingsState = Arc>; +pub type DownloadSettingsState = Arc>>; pub fn create_download_settings_state() -> Result { let store = DownloadSettingsStore::new()?; - Ok(Arc::new(Mutex::new(store))) + Ok(Arc::new(Mutex::new(Some(store)))) +} + +pub fn create_empty_download_settings_state() -> DownloadSettingsState { + Arc::new(Mutex::new(None)) } // Tauri commands @@ -114,7 +126,8 @@ pub fn get_download_settings( state: tauri::State, ) -> Result { log::info!("Command: get_download_settings"); - let store = state.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.get_settings() } @@ -124,7 +137,7 @@ pub fn set_download_root( state: tauri::State, ) -> Result<(), String> { log::info!("Command: set_download_root to: {}", path); - + // Basic validation let path_obj = std::path::Path::new(&path); if !path_obj.exists() { @@ -134,7 +147,8 @@ pub fn set_download_root( return Err("Path is not a directory".to_string()); } - let store = state.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_download_root(&path) } @@ -144,16 +158,17 @@ pub fn set_show_downloads_in_library( state: tauri::State, ) -> Result<(), String> { log::info!("Command: set_show_downloads_in_library to: {}", show); - let store = state.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_show_in_library(show) } #[tauri::command] pub fn validate_download_root(path: String) -> Result { log::info!("Command: validate_download_root: {}", path); - + let path_obj = std::path::Path::new(&path); - + if !path_obj.exists() { return Ok(false); } diff --git a/src-tauri/src/config/favorites_cache.rs b/src-tauri/src/config/favorites_cache.rs index 0745a7f87..dfb30fc12 100644 --- a/src-tauri/src/config/favorites_cache.rs +++ b/src-tauri/src/config/favorites_cache.rs @@ -12,6 +12,7 @@ use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::sync::{Arc, Mutex}; /// Represents a cached favorite track @@ -37,15 +38,11 @@ pub struct FavoritesCacheStore { } impl FavoritesCacheStore { - pub fn new() -> Result { - let data_dir = dirs::data_dir() - .ok_or("Could not determine data directory")? - .join("qbz"); - - std::fs::create_dir_all(&data_dir) + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; - let db_path = data_dir.join("favorites_cache.db"); + let db_path = dir.join(db_name); let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open favorites cache database: {}", e))?; @@ -80,6 +77,17 @@ impl FavoritesCacheStore { Ok(Self { conn }) } + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "favorites_cache.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "favorites_cache.db") + } + // ============ Track favorites ============ pub fn get_favorite_track_ids(&self) -> Result, String> { @@ -298,16 +306,37 @@ impl FavoritesCacheStore { // ============ Tauri State ============ pub struct FavoritesCacheState { - pub store: Arc>, + pub store: Arc>>, } impl FavoritesCacheState { pub fn new() -> Result { let store = FavoritesCacheStore::new()?; Ok(Self { - store: Arc::new(Mutex::new(store)), + store: Arc::new(Mutex::new(Some(store))), }) } + + pub fn new_empty() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let new_store = FavoritesCacheStore::new_at(base_dir)?; + let mut guard = self.store.lock() + .map_err(|_| "Failed to lock favorites cache store".to_string())?; + *guard = Some(new_store); + Ok(()) + } + + pub fn teardown(&self) -> Result<(), String> { + let mut guard = self.store.lock() + .map_err(|_| "Failed to lock favorites cache store".to_string())?; + *guard = None; + Ok(()) + } } // ============ Tauri Commands ============ @@ -317,10 +346,11 @@ impl FavoritesCacheState { pub fn get_cached_favorite_tracks( state: tauri::State, ) -> Result, String> { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites cache store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.get_favorite_track_ids() } @@ -329,10 +359,11 @@ pub fn get_cached_favorite_tracks( pub fn get_cached_favorite_albums( state: tauri::State, ) -> Result, String> { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites cache store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.get_favorite_album_ids() } @@ -341,10 +372,11 @@ pub fn get_cached_favorite_albums( pub fn get_cached_favorite_artists( state: tauri::State, ) -> Result, String> { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites cache store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.get_favorite_artist_ids() } @@ -354,10 +386,11 @@ pub fn cache_favorite_track( track_id: i64, state: tauri::State, ) -> Result<(), String> { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites cache store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.add_favorite_track(track_id) } @@ -367,10 +400,11 @@ pub fn uncache_favorite_track( track_id: i64, state: tauri::State, ) -> Result<(), String> { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites cache store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.remove_favorite_track(track_id) } @@ -380,10 +414,11 @@ pub fn cache_favorite_album( album_id: String, state: tauri::State, ) -> Result<(), String> { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites cache store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.add_favorite_album(&album_id) } @@ -393,10 +428,11 @@ pub fn uncache_favorite_album( album_id: String, state: tauri::State, ) -> Result<(), String> { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites cache store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.remove_favorite_album(&album_id) } @@ -406,10 +442,11 @@ pub fn cache_favorite_artist( artist_id: i64, state: tauri::State, ) -> Result<(), String> { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites cache store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.add_favorite_artist(artist_id) } @@ -419,10 +456,11 @@ pub fn uncache_favorite_artist( artist_id: i64, state: tauri::State, ) -> Result<(), String> { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites cache store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.remove_favorite_artist(artist_id) } @@ -432,10 +470,11 @@ pub fn sync_cached_favorite_tracks( track_ids: Vec, state: tauri::State, ) -> Result<(), String> { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites cache store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.sync_favorite_tracks(&track_ids) } @@ -445,10 +484,11 @@ pub fn sync_cached_favorite_albums( album_ids: Vec, state: tauri::State, ) -> Result<(), String> { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites cache store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.sync_favorite_albums(&album_ids) } @@ -458,10 +498,11 @@ pub fn sync_cached_favorite_artists( artist_ids: Vec, state: tauri::State, ) -> Result<(), String> { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites cache store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.sync_favorite_artists(&artist_ids) } @@ -470,9 +511,10 @@ pub fn sync_cached_favorite_artists( pub fn clear_favorites_cache( state: tauri::State, ) -> Result<(), String> { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites cache store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.clear_all() } diff --git a/src-tauri/src/config/favorites_preferences.rs b/src-tauri/src/config/favorites_preferences.rs index f0495e780..ab3ae2967 100644 --- a/src-tauri/src/config/favorites_preferences.rs +++ b/src-tauri/src/config/favorites_preferences.rs @@ -35,15 +35,11 @@ pub struct FavoritesPreferencesStore { } impl FavoritesPreferencesStore { - pub fn new() -> Result { - let data_dir = dirs::data_dir() - .ok_or("Could not determine data directory")? - .join("qbz"); - - std::fs::create_dir_all(&data_dir) + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; - let db_path = data_dir.join("favorites_preferences.db"); + let db_path = dir.join(db_name); let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open favorites preferences database: {}", e))?; @@ -62,7 +58,7 @@ impl FavoritesPreferencesStore { let has_icon_background = conn .prepare("SELECT icon_background FROM favorites_preferences LIMIT 1") .is_ok(); - + if !has_icon_background { conn.execute( "ALTER TABLE favorites_preferences ADD COLUMN icon_background TEXT", @@ -74,6 +70,17 @@ impl FavoritesPreferencesStore { Ok(Self { conn }) } + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "favorites_preferences.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "favorites_preferences.db") + } + fn favorites_icon_dir() -> Result { let cache_dir = dirs::cache_dir() .ok_or("Could not determine cache directory")? @@ -127,7 +134,7 @@ impl FavoritesPreferencesStore { pub fn get_preferences(&self) -> Result { let mut stmt = self.conn.prepare("SELECT custom_icon_path, custom_icon_preset, icon_background, tab_order FROM favorites_preferences WHERE id = 1") .map_err(|e| format!("Failed to prepare select: {}", e))?; - + let result = stmt.query_row([], |row| { let custom_icon_path: Option = row.get(0)?; let custom_icon_preset: Option = row.get(1)?; @@ -136,7 +143,7 @@ impl FavoritesPreferencesStore { let custom_icon_path = custom_icon_path .and_then(|value| if value.trim().is_empty() { None } else { Some(value) }); - + let tab_order: Vec = serde_json::from_str(&tab_order_str).unwrap_or_else(|_| { vec![ "tracks".to_string(), @@ -194,7 +201,7 @@ impl FavoritesPreferencesStore { let tab_order_str = serde_json::to_string(&prefs.tab_order) .map_err(|e| format!("Failed to serialize tab_order: {}", e))?; - + self.conn.execute( "INSERT OR REPLACE INTO favorites_preferences (id, custom_icon_path, custom_icon_preset, icon_background, tab_order) VALUES (1, ?1, ?2, ?3, ?4)", @@ -206,26 +213,48 @@ impl FavoritesPreferencesStore { } pub struct FavoritesPreferencesState { - pub store: Arc>, + pub store: Arc>>, } impl FavoritesPreferencesState { pub fn new() -> Result { let store = FavoritesPreferencesStore::new()?; Ok(Self { - store: Arc::new(Mutex::new(store)), + store: Arc::new(Mutex::new(Some(store))), }) } + + pub fn new_empty() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let new_store = FavoritesPreferencesStore::new_at(base_dir)?; + let mut guard = self.store.lock() + .map_err(|_| "Failed to lock favorites preferences store".to_string())?; + *guard = Some(new_store); + Ok(()) + } + + pub fn teardown(&self) -> Result<(), String> { + let mut guard = self.store.lock() + .map_err(|_| "Failed to lock favorites preferences store".to_string())?; + *guard = None; + Ok(()) + } } #[tauri::command] pub fn get_favorites_preferences( state: tauri::State, ) -> Result { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites preferences store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.get_preferences() } @@ -234,10 +263,11 @@ pub fn save_favorites_preferences( prefs: FavoritesPreferences, state: tauri::State, ) -> Result { - let store = state + let guard = state .store .lock() .map_err(|_| "Failed to lock favorites preferences store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.save_preferences(prefs) } @@ -251,25 +281,25 @@ pub fn create_table(conn: &Connection) -> Result<()> { )", [], )?; - + // Migration: Add icon_background column if it doesn't exist let has_icon_background = conn .prepare("SELECT icon_background FROM favorites_preferences LIMIT 1") .is_ok(); - + if !has_icon_background { conn.execute( "ALTER TABLE favorites_preferences ADD COLUMN icon_background TEXT", [], )?; } - + Ok(()) } pub fn load_preferences(conn: &Connection) -> Result { let mut stmt = conn.prepare("SELECT custom_icon_path, custom_icon_preset, icon_background, tab_order FROM favorites_preferences WHERE id = 1")?; - + let result = stmt.query_row([], |row| { let custom_icon_path: Option = row.get(0)?; let custom_icon_preset: Option = row.get(1)?; @@ -278,7 +308,7 @@ pub fn load_preferences(conn: &Connection) -> Result { let custom_icon_path = custom_icon_path .and_then(|value| if value.trim().is_empty() { None } else { Some(value) }); - + let tab_order: Vec = serde_json::from_str(&tab_order_str).unwrap_or_else(|_| { vec![ "tracks".to_string(), @@ -305,7 +335,7 @@ pub fn load_preferences(conn: &Connection) -> Result { pub fn save_preferences(conn: &Connection, prefs: &FavoritesPreferences) -> Result<()> { let tab_order_str = serde_json::to_string(&prefs.tab_order).unwrap(); - + conn.execute( "INSERT OR REPLACE INTO favorites_preferences (id, custom_icon_path, custom_icon_preset, icon_background, tab_order) VALUES (1, ?1, ?2, ?3, ?4)", diff --git a/src-tauri/src/config/legal_settings.rs b/src-tauri/src/config/legal_settings.rs index eaf11bd47..b7cfb8d39 100644 --- a/src-tauri/src/config/legal_settings.rs +++ b/src-tauri/src/config/legal_settings.rs @@ -5,6 +5,7 @@ use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::sync::{Arc, Mutex}; use log::info; @@ -27,15 +28,11 @@ pub struct LegalSettingsStore { } impl LegalSettingsStore { - pub fn new() -> Result { - let data_dir = dirs::data_dir() - .ok_or("Could not determine data directory")? - .join("qbz"); - - std::fs::create_dir_all(&data_dir) + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; - let db_path = data_dir.join("legal_settings.db"); + let db_path = dir.join(db_name); let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open legal settings database: {}", e))?; @@ -58,6 +55,17 @@ impl LegalSettingsStore { Ok(Self { conn }) } + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "legal_settings.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "legal_settings.db") + } + pub fn get_settings(&self) -> Result { self.conn .query_row( @@ -86,7 +94,16 @@ impl LegalSettingsStore { } } -pub type LegalSettingsState = Arc>; +pub type LegalSettingsState = Arc>>; + +pub fn create_legal_settings_state() -> Result { + let store = LegalSettingsStore::new()?; + Ok(Arc::new(Mutex::new(Some(store)))) +} + +pub fn create_empty_legal_settings_state() -> LegalSettingsState { + Arc::new(Mutex::new(None)) +} // Tauri commands @@ -94,7 +111,8 @@ pub type LegalSettingsState = Arc>; pub fn get_legal_settings( state: tauri::State<'_, LegalSettingsState>, ) -> Result { - let store = state.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.get_settings() } @@ -102,7 +120,8 @@ pub fn get_legal_settings( pub fn get_qobuz_tos_accepted( state: tauri::State<'_, LegalSettingsState>, ) -> Result { - let store = state.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; let settings = store.get_settings()?; Ok(settings.qobuz_tos_accepted) } @@ -112,6 +131,7 @@ pub fn set_qobuz_tos_accepted( state: tauri::State<'_, LegalSettingsState>, accepted: bool, ) -> Result<(), String> { - let store = state.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard = state.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; store.set_qobuz_tos_accepted(accepted) } diff --git a/src-tauri/src/config/playback_preferences.rs b/src-tauri/src/config/playback_preferences.rs index 35668b4c9..3a80e1f98 100644 --- a/src-tauri/src/config/playback_preferences.rs +++ b/src-tauri/src/config/playback_preferences.rs @@ -4,6 +4,7 @@ use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::sync::{Arc, Mutex}; use log::info; @@ -64,15 +65,11 @@ pub struct PlaybackPreferencesStore { } impl PlaybackPreferencesStore { - pub fn new() -> Result { - let data_dir = dirs::data_dir() - .ok_or("Could not determine data directory")? - .join("qbz"); - - std::fs::create_dir_all(&data_dir) + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; - let db_path = data_dir.join("playback_preferences.db"); + let db_path = dir.join(db_name); let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open playback preferences database: {}", e))?; @@ -118,6 +115,17 @@ impl PlaybackPreferencesStore { Ok(Self { conn }) } + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "playback_preferences.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "playback_preferences.db") + } + pub fn get_preferences(&self) -> Result { self.conn .query_row( @@ -158,27 +166,54 @@ impl PlaybackPreferencesStore { /// Global state wrapper for thread-safe access pub struct PlaybackPreferencesState { - store: Arc>, + pub store: Arc>>, } impl PlaybackPreferencesState { pub fn new() -> Result { let store = PlaybackPreferencesStore::new()?; Ok(Self { - store: Arc::new(Mutex::new(store)), + store: Arc::new(Mutex::new(Some(store))), }) } + pub fn new_empty() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let new_store = PlaybackPreferencesStore::new_at(base_dir)?; + let mut guard = self.store.lock() + .map_err(|_| "Failed to lock playback preferences store".to_string())?; + *guard = Some(new_store); + Ok(()) + } + + pub fn teardown(&self) -> Result<(), String> { + let mut guard = self.store.lock() + .map_err(|_| "Failed to lock playback preferences store".to_string())?; + *guard = None; + Ok(()) + } + pub fn get_preferences(&self) -> Result { - self.store.lock().unwrap().get_preferences() + let guard = self.store.lock().map_err(|_| "Failed to lock playback preferences store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.get_preferences() } pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> { - self.store.lock().unwrap().set_autoplay_mode(mode) + let guard = self.store.lock().map_err(|_| "Failed to lock playback preferences store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_autoplay_mode(mode) } pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> { - self.store.lock().unwrap().set_show_context_icon(show) + let guard = self.store.lock().map_err(|_| "Failed to lock playback preferences store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_show_context_icon(show) } } diff --git a/src-tauri/src/config/remote_control_settings.rs b/src-tauri/src/config/remote_control_settings.rs index 07d134deb..55964b3e6 100644 --- a/src-tauri/src/config/remote_control_settings.rs +++ b/src-tauri/src/config/remote_control_settings.rs @@ -9,6 +9,7 @@ use base64::Engine; use rand::RngCore; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::sync::{Arc, Mutex}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -35,15 +36,11 @@ pub struct RemoteControlSettingsStore { } impl RemoteControlSettingsStore { - pub fn new() -> Result { - let data_dir = dirs::data_dir() - .ok_or("Could not determine data directory")? - .join("qbz"); - - std::fs::create_dir_all(&data_dir) + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; - let db_path = data_dir.join("remote_control_settings.db"); + let db_path = dir.join(db_name); let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open remote control settings database: {}", e))?; @@ -69,6 +66,17 @@ impl RemoteControlSettingsStore { Ok(Self { conn }) } + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "remote_control_settings.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "remote_control_settings.db") + } + pub fn get_settings(&self) -> Result { let mut settings = self.conn .query_row( @@ -146,49 +154,75 @@ impl RemoteControlSettingsStore { /// Global state wrapper for thread-safe access pub struct RemoteControlSettingsState { - store: Arc>, + pub store: Arc>>, } impl RemoteControlSettingsState { pub fn new() -> Result { Ok(Self { - store: Arc::new(Mutex::new(RemoteControlSettingsStore::new()?)), + store: Arc::new(Mutex::new(Some(RemoteControlSettingsStore::new()?))), }) } + pub fn new_empty() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let new_store = RemoteControlSettingsStore::new_at(base_dir)?; + let mut guard = self.store.lock() + .map_err(|_| "Failed to lock remote control settings store".to_string())?; + *guard = Some(new_store); + Ok(()) + } + + pub fn teardown(&self) -> Result<(), String> { + let mut guard = self.store.lock() + .map_err(|_| "Failed to lock remote control settings store".to_string())?; + *guard = None; + Ok(()) + } + pub fn get_settings(&self) -> Result { - self.store + let guard = self.store .lock() - .map_err(|e| format!("Lock error: {}", e))? - .get_settings() + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.get_settings() } pub fn set_enabled(&self, enabled: bool) -> Result<(), String> { - self.store + let guard = self.store .lock() - .map_err(|e| format!("Lock error: {}", e))? - .set_enabled(enabled) + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_enabled(enabled) } pub fn set_port(&self, port: u16) -> Result<(), String> { - self.store + let guard = self.store .lock() - .map_err(|e| format!("Lock error: {}", e))? - .set_port(port) + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_port(port) } pub fn set_secure(&self, secure: bool) -> Result<(), String> { - self.store + let guard = self.store .lock() - .map_err(|e| format!("Lock error: {}", e))? - .set_secure(secure) + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_secure(secure) } pub fn regenerate_token(&self) -> Result { - self.store + let guard = self.store .lock() - .map_err(|e| format!("Lock error: {}", e))? - .regenerate_token() + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.regenerate_token() } } @@ -250,15 +284,11 @@ pub struct AllowedOriginsStore { } impl AllowedOriginsStore { - pub fn new() -> Result { - let data_dir = dirs::data_dir() - .ok_or("Could not determine data directory")? - .join("qbz"); - - std::fs::create_dir_all(&data_dir) + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; - let db_path = data_dir.join("remote_control_settings.db"); + let db_path = dir.join(db_name); let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open allowed origins database: {}", e))?; @@ -290,6 +320,17 @@ impl AllowedOriginsStore { Ok(Self { conn }) } + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "remote_control_settings.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "remote_control_settings.db") + } + /// Get all allowed origins pub fn get_origins(&self) -> Result, String> { let mut stmt = self.conn @@ -372,48 +413,77 @@ impl AllowedOriginsStore { /// Global state wrapper for thread-safe access to allowed origins pub struct AllowedOriginsState { - store: Arc>, + pub store: Arc>>, } impl AllowedOriginsState { pub fn new() -> Result { Ok(Self { - store: Arc::new(Mutex::new(AllowedOriginsStore::new()?)), + store: Arc::new(Mutex::new(Some(AllowedOriginsStore::new()?))), }) } + pub fn new_empty() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let new_store = AllowedOriginsStore::new_at(base_dir)?; + let mut guard = self.store.lock() + .map_err(|_| "Failed to lock allowed origins store".to_string())?; + *guard = Some(new_store); + Ok(()) + } + + pub fn teardown(&self) -> Result<(), String> { + let mut guard = self.store.lock() + .map_err(|_| "Failed to lock allowed origins store".to_string())?; + *guard = None; + Ok(()) + } + pub fn get_origins(&self) -> Result, String> { - self.store + let guard = self.store .lock() - .map_err(|e| format!("Lock error: {}", e))? - .get_origins() + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.get_origins() } pub fn is_origin_allowed(&self, origin: &str) -> bool { self.store .lock() - .map(|s| s.is_origin_allowed(origin)) + .map(|guard| { + guard.as_ref() + .map(|s| s.is_origin_allowed(origin)) + .unwrap_or(false) + }) .unwrap_or(false) } pub fn add_origin(&self, origin: &str) -> Result { - self.store + let guard = self.store .lock() - .map_err(|e| format!("Lock error: {}", e))? - .add_origin(origin) + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.add_origin(origin) } pub fn remove_origin(&self, id: i64) -> Result<(), String> { - self.store + let guard = self.store .lock() - .map_err(|e| format!("Lock error: {}", e))? - .remove_origin(id) + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.remove_origin(id) } pub fn restore_defaults(&self) -> Result<(), String> { - self.store + let guard = self.store .lock() - .map_err(|e| format!("Lock error: {}", e))? - .restore_defaults() + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.restore_defaults() } } diff --git a/src-tauri/src/config/subscription_state.rs b/src-tauri/src/config/subscription_state.rs index 0c4185f14..48fe15576 100644 --- a/src-tauri/src/config/subscription_state.rs +++ b/src-tauri/src/config/subscription_state.rs @@ -6,6 +6,7 @@ use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::sync::{Arc, Mutex}; const GRACE_PERIOD_SECS: i64 = 3 * 24 * 60 * 60; // 3 days @@ -37,15 +38,11 @@ pub struct SubscriptionStateStore { } impl SubscriptionStateStore { - pub fn new() -> Result { - let data_dir = dirs::data_dir() - .ok_or("Could not determine data directory")? - .join("qbz"); - - std::fs::create_dir_all(&data_dir) + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; - let db_path = data_dir.join("subscription_state.db"); + let db_path = dir.join(db_name); let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open subscription state database: {}", e))?; @@ -64,6 +61,17 @@ impl SubscriptionStateStore { Ok(Self { conn }) } + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "subscription_state.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "subscription_state.db") + } + pub fn get_state(&self) -> Result { self.conn .query_row( @@ -138,10 +146,13 @@ impl SubscriptionStateStore { } } -pub type SubscriptionStateState = Arc>; +pub type SubscriptionStateState = Arc>>; pub fn create_subscription_state() -> Result { let store = SubscriptionStateStore::new()?; - Ok(Arc::new(Mutex::new(store))) + Ok(Arc::new(Mutex::new(Some(store)))) } +pub fn create_empty_subscription_state() -> SubscriptionStateState { + Arc::new(Mutex::new(None)) +} diff --git a/src-tauri/src/config/tray_settings.rs b/src-tauri/src/config/tray_settings.rs index 72d9d1e34..902b80c9c 100644 --- a/src-tauri/src/config/tray_settings.rs +++ b/src-tauri/src/config/tray_settings.rs @@ -7,6 +7,7 @@ use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::sync::{Arc, Mutex}; use log::info; @@ -35,15 +36,11 @@ pub struct TraySettingsStore { } impl TraySettingsStore { - pub fn new() -> Result { - let data_dir = dirs::data_dir() - .ok_or("Could not determine data directory")? - .join("qbz"); - - std::fs::create_dir_all(&data_dir) + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; - let db_path = data_dir.join("tray_settings.db"); + let db_path = dir.join(db_name); let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open tray settings database: {}", e))?; @@ -69,6 +66,17 @@ impl TraySettingsStore { Ok(Self { conn }) } + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "tray_settings.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "tray_settings.db") + } + pub fn get_settings(&self) -> Result { self.conn .query_row( @@ -121,31 +129,60 @@ impl TraySettingsStore { /// Global state wrapper for thread-safe access pub struct TraySettingsState { - store: Arc>, + pub store: Arc>>, } impl TraySettingsState { pub fn new() -> Result { let store = TraySettingsStore::new()?; Ok(Self { - store: Arc::new(Mutex::new(store)), + store: Arc::new(Mutex::new(Some(store))), }) } + pub fn new_empty() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let new_store = TraySettingsStore::new_at(base_dir)?; + let mut guard = self.store.lock() + .map_err(|_| "Failed to lock tray settings store".to_string())?; + *guard = Some(new_store); + Ok(()) + } + + pub fn teardown(&self) -> Result<(), String> { + let mut guard = self.store.lock() + .map_err(|_| "Failed to lock tray settings store".to_string())?; + *guard = None; + Ok(()) + } + pub fn get_settings(&self) -> Result { - self.store.lock().unwrap().get_settings() + let guard = self.store.lock().map_err(|_| "Failed to lock tray settings store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.get_settings() } pub fn set_enable_tray(&self, value: bool) -> Result<(), String> { - self.store.lock().unwrap().set_enable_tray(value) + let guard = self.store.lock().map_err(|_| "Failed to lock tray settings store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_enable_tray(value) } pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> { - self.store.lock().unwrap().set_minimize_to_tray(value) + let guard = self.store.lock().map_err(|_| "Failed to lock tray settings store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_minimize_to_tray(value) } pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> { - self.store.lock().unwrap().set_close_to_tray(value) + let guard = self.store.lock().map_err(|_| "Failed to lock tray settings store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_close_to_tray(value) } } diff --git a/src-tauri/src/library/mod.rs b/src-tauri/src/library/mod.rs index 11c54a5dd..f0c2072f3 100644 --- a/src-tauri/src/library/mod.rs +++ b/src-tauri/src/library/mod.rs @@ -26,7 +26,7 @@ pub use models::*; pub use tag_sidecar::*; pub use scanner::{LibraryScanner, ScanResult}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; use std::sync::Arc; use tokio::sync::Mutex; @@ -56,7 +56,30 @@ pub fn init_library_state() -> Result { let db = LibraryDatabase::open(&db_path)?; Ok(LibraryState { - db: Arc::new(Mutex::new(db)), + db: Arc::new(Mutex::new(Some(db))), + scan_progress: Arc::new(Mutex::new(ScanProgress::default())), + scan_cancel: Arc::new(AtomicBool::new(false)), + }) +} + +/// Initialize library state with no database (for deferred init) +pub fn init_library_state_empty() -> LibraryState { + LibraryState { + db: Arc::new(Mutex::new(None)), + scan_progress: Arc::new(Mutex::new(ScanProgress::default())), + scan_cancel: Arc::new(AtomicBool::new(false)), + } +} + +/// Initialize library state at a specific directory +pub fn init_library_state_at(base_dir: &Path) -> Result { + std::fs::create_dir_all(base_dir) + .map_err(|e| LibraryError::Database(format!("Failed to create directory: {}", e)))?; + let db_path = base_dir.join("library.db"); + let db = LibraryDatabase::open(&db_path)?; + + Ok(LibraryState { + db: Arc::new(Mutex::new(Some(db))), scan_progress: Arc::new(Mutex::new(ScanProgress::default())), scan_cancel: Arc::new(AtomicBool::new(false)), }) diff --git a/src-tauri/src/listenbrainz/cache.rs b/src-tauri/src/listenbrainz/cache.rs index 88546b009..c68739ac3 100644 --- a/src-tauri/src/listenbrainz/cache.rs +++ b/src-tauri/src/listenbrainz/cache.rs @@ -18,7 +18,7 @@ const QUEUE_MAX_SIZE: i64 = 500; /// ListenBrainz cache state for Tauri pub struct ListenBrainzCacheState { - pub cache: Arc>, + pub cache: Arc>>, } impl ListenBrainzCacheState { @@ -37,9 +37,32 @@ impl ListenBrainzCacheState { log::info!("ListenBrainz cache initialized at {:?}", db_path); Ok(Self { - cache: Arc::new(Mutex::new(cache)), + cache: Arc::new(Mutex::new(Some(cache))), }) } + + pub fn new_empty() -> Self { + Self { + cache: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let cache_dir = base_dir.join("cache"); + std::fs::create_dir_all(&cache_dir) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + let db_path = cache_dir.join("listenbrainz_cache.db"); + let new_cache = ListenBrainzCache::new(&db_path)?; + log::info!("ListenBrainz cache initialized at {:?}", db_path); + let mut guard = self.cache.blocking_lock(); + *guard = Some(new_cache); + Ok(()) + } + + pub fn teardown(&self) { + let mut guard = self.cache.blocking_lock(); + *guard = None; + } } /// ListenBrainz SQLite cache diff --git a/src-tauri/src/listenbrainz/mod.rs b/src-tauri/src/listenbrainz/mod.rs index d2f3b2c07..fb1ffaaf3 100644 --- a/src-tauri/src/listenbrainz/mod.rs +++ b/src-tauri/src/listenbrainz/mod.rs @@ -38,13 +38,14 @@ pub use models::{ SubmitListensPayload, TrackMetadata, UserInfo, }; +use std::path::Path; use std::sync::Arc; use tokio::sync::Mutex; /// Shared state wrapper for Tauri pub struct ListenBrainzSharedState { pub client: Arc>, - pub cache: Arc>, + pub cache: Arc>>, } impl ListenBrainzSharedState { @@ -53,10 +54,14 @@ impl ListenBrainzSharedState { // Check for saved credentials and enabled state let (token, user_name, enabled) = { - let cache = cache_state.cache.blocking_lock(); - let (token, user_name) = cache.get_credentials().unwrap_or((None, None)); - let enabled = cache.is_enabled().unwrap_or(true); - (token, user_name, enabled) + let cache_guard = cache_state.cache.blocking_lock(); + if let Some(cache) = cache_guard.as_ref() { + let (token, user_name) = cache.get_credentials().unwrap_or((None, None)); + let enabled = cache.is_enabled().unwrap_or(true); + (token, user_name, enabled) + } else { + (None, None, true) + } }; // Create client with saved config @@ -76,4 +81,45 @@ impl ListenBrainzSharedState { cache: cache_state.cache, }) } + + pub fn new_empty() -> Self { + let client = ListenBrainzClient::with_config(ListenBrainzConfig { + enabled: true, + token: None, + user_name: None, + }); + Self { + client: Arc::new(Mutex::new(client)), + cache: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let cache_dir = base_dir.join("cache"); + std::fs::create_dir_all(&cache_dir) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + let db_path = cache_dir.join("listenbrainz_cache.db"); + let new_cache = ListenBrainzCache::new(&db_path)?; + log::info!("ListenBrainz cache initialized at {:?}", db_path); + + // Restore credentials from new cache + let (token, user_name) = { + let (token, user_name) = new_cache.get_credentials().unwrap_or((None, None)); + (token, user_name) + }; + + let mut guard = self.cache.blocking_lock(); + *guard = Some(new_cache); + + if token.is_some() && user_name.is_some() { + log::info!("ListenBrainz: restored session for user {:?}", user_name); + } + + Ok(()) + } + + pub fn teardown(&self) { + let mut guard = self.cache.blocking_lock(); + *guard = None; + } } diff --git a/src-tauri/src/lyrics/commands.rs b/src-tauri/src/lyrics/commands.rs index 891eadd59..673098cfa 100644 --- a/src-tauri/src/lyrics/commands.rs +++ b/src-tauri/src/lyrics/commands.rs @@ -26,7 +26,8 @@ pub async fn lyrics_get( // Try cache by track_id first, then by key { - let db = state.db.lock().await; + let db_opt__ = state.db.lock().await; + let db = db_opt__.as_ref().ok_or("No active session - please log in")?; if let Some(id) = track_id { if let Ok(Some(payload)) = db.get_by_track_id(id) { return Ok(Some(payload)); @@ -52,7 +53,8 @@ pub async fn lyrics_get( cached: false, }; - let db = state.db.lock().await; + let db_opt__ = state.db.lock().await; + let db = db_opt__.as_ref().ok_or("No active session - please log in")?; db.upsert(&cache_key, &payload)?; return Ok(Some(payload)); } @@ -70,7 +72,8 @@ pub async fn lyrics_get( cached: false, }; - let db = state.db.lock().await; + let db_opt__ = state.db.lock().await; + let db = db_opt__.as_ref().ok_or("No active session - please log in")?; db.upsert(&cache_key, &payload)?; return Ok(Some(payload)); } @@ -80,7 +83,8 @@ pub async fn lyrics_get( #[tauri::command] pub async fn lyrics_clear_cache(state: State<'_, LyricsState>) -> Result<(), String> { - let db = state.db.lock().await; + let db_opt__ = state.db.lock().await; + let db = db_opt__.as_ref().ok_or("No active session - please log in")?; db.clear() } @@ -94,7 +98,8 @@ pub struct LyricsCacheStats { #[tauri::command] pub async fn lyrics_get_cache_stats(state: State<'_, LyricsState>) -> Result { let entries = { - let db = state.db.lock().await; + let db_opt__ = state.db.lock().await; + let db = db_opt__.as_ref().ok_or("No active session - please log in")?; db.count_entries()? }; diff --git a/src-tauri/src/lyrics/mod.rs b/src-tauri/src/lyrics/mod.rs index 4a4a765bb..216d6e99c 100644 --- a/src-tauri/src/lyrics/mod.rs +++ b/src-tauri/src/lyrics/mod.rs @@ -7,6 +7,7 @@ pub mod commands; pub mod providers; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::sync::Arc; use tokio::sync::Mutex; @@ -51,7 +52,7 @@ impl LyricsProvider { /// Lyrics state shared across commands pub struct LyricsState { - pub db: Arc>, + pub db: Arc>>, } impl LyricsState { @@ -68,9 +69,31 @@ impl LyricsState { let db = LyricsCacheDb::new(&db_path)?; Ok(Self { - db: Arc::new(Mutex::new(db)), + db: Arc::new(Mutex::new(Some(db))), }) } + + pub fn new_empty() -> Self { + Self { + db: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let cache_dir = base_dir.join("lyrics"); + std::fs::create_dir_all(&cache_dir) + .map_err(|e| format!("Failed to create lyrics cache directory: {}", e))?; + let db_path = cache_dir.join("lyrics.db"); + let new_db = LyricsCacheDb::new(&db_path)?; + let mut guard = self.db.blocking_lock(); + *guard = Some(new_db); + Ok(()) + } + + pub fn teardown(&self) { + let mut guard = self.db.blocking_lock(); + *guard = None; + } } pub(crate) fn build_cache_key(title: &str, artist: &str, duration_secs: Option) -> String { diff --git a/src-tauri/src/musicbrainz/cache.rs b/src-tauri/src/musicbrainz/cache.rs index 3ada13c6a..3dddc2262 100644 --- a/src-tauri/src/musicbrainz/cache.rs +++ b/src-tauri/src/musicbrainz/cache.rs @@ -24,7 +24,7 @@ const RELATIONS_TTL_SECS: i64 = 7 * 24 * 60 * 60; /// MusicBrainz cache state shared across commands pub struct MusicBrainzCacheState { - pub cache: Arc>, + pub cache: Arc>>, } impl MusicBrainzCacheState { @@ -43,9 +43,32 @@ impl MusicBrainzCacheState { log::info!("MusicBrainz cache initialized at {:?}", db_path); Ok(Self { - cache: Arc::new(Mutex::new(cache)), + cache: Arc::new(Mutex::new(Some(cache))), }) } + + pub fn new_empty() -> Self { + Self { + cache: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let cache_dir = base_dir.join("cache"); + std::fs::create_dir_all(&cache_dir) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + let db_path = cache_dir.join("musicbrainz_cache.db"); + let new_cache = MusicBrainzCache::new(&db_path)?; + log::info!("MusicBrainz cache initialized at {:?}", db_path); + let mut guard = self.cache.blocking_lock(); + *guard = Some(new_cache); + Ok(()) + } + + pub fn teardown(&self) { + let mut guard = self.cache.blocking_lock(); + *guard = None; + } } /// MusicBrainz SQLite cache diff --git a/src-tauri/src/musicbrainz/mod.rs b/src-tauri/src/musicbrainz/mod.rs index 08c64cccc..60ce93011 100644 --- a/src-tauri/src/musicbrainz/mod.rs +++ b/src-tauri/src/musicbrainz/mod.rs @@ -28,13 +28,14 @@ pub use models::{ ResolvedTrack, Tag, }; +use std::path::Path; use std::sync::Arc; use tokio::sync::Mutex; /// Shared state wrapper for Tauri pub struct MusicBrainzSharedState { pub client: Arc, - pub cache: Arc>, + pub cache: Arc>>, } impl MusicBrainzSharedState { @@ -45,4 +46,28 @@ impl MusicBrainzSharedState { cache: cache_state.cache, }) } + + pub fn new_empty() -> Self { + Self { + client: Arc::new(MusicBrainzClient::new()), + cache: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let cache_dir = base_dir.join("cache"); + std::fs::create_dir_all(&cache_dir) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + let db_path = cache_dir.join("musicbrainz_cache.db"); + let new_cache = MusicBrainzCache::new(&db_path)?; + log::info!("MusicBrainz cache initialized at {:?}", db_path); + let mut guard = self.cache.blocking_lock(); + *guard = Some(new_cache); + Ok(()) + } + + pub fn teardown(&self) { + let mut guard = self.cache.blocking_lock(); + *guard = None; + } } diff --git a/src-tauri/src/offline/mod.rs b/src-tauri/src/offline/mod.rs index a44a7800f..9f473d50f 100644 --- a/src-tauri/src/offline/mod.rs +++ b/src-tauri/src/offline/mod.rs @@ -91,6 +91,17 @@ impl OfflineStore { .map_err(|e| format!("Failed to create data directory: {}", e))?; let db_path = data_dir.join("offline_settings.db"); + Self::open_at(&db_path) + } + + pub fn new_at(base_dir: &std::path::Path) -> Result { + std::fs::create_dir_all(base_dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + let db_path = base_dir.join("offline_settings.db"); + Self::open_at(&db_path) + } + + fn open_at(db_path: &std::path::Path) -> Result { let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open offline settings database: {}", e))?; @@ -503,15 +514,34 @@ impl OfflineStore { /// Thread-safe wrapper for OfflineStore pub struct OfflineState { - pub store: Arc>, + pub store: Arc>>, } impl OfflineState { pub fn new() -> Result { Ok(Self { - store: Arc::new(Mutex::new(OfflineStore::new()?)), + store: Arc::new(Mutex::new(Some(OfflineStore::new()?))), }) } + + pub fn new_empty() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &std::path::Path) -> Result<(), String> { + let new_store = OfflineStore::new_at(base_dir)?; + let mut guard = self.store.lock().map_err(|e| format!("Lock error: {}", e))?; + *guard = Some(new_store); + Ok(()) + } + + pub fn teardown(&self) { + if let Ok(mut guard) = self.store.lock() { + *guard = None; + } + } } /// Counter for alternating between neutral endpoint and Qobuz checks. @@ -595,7 +625,8 @@ pub mod commands { offline_state: State<'_, OfflineState>, ) -> Result { let settings = { - let store = offline_state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = offline_state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.get_settings()? }; @@ -631,7 +662,8 @@ pub mod commands { pub fn get_offline_settings( state: State<'_, OfflineState>, ) -> Result { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.get_settings() } @@ -643,7 +675,8 @@ pub mod commands { app_handle: AppHandle, ) -> Result { { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.set_manual_offline_mode(enabled)?; } @@ -662,7 +695,8 @@ pub mod commands { enabled: bool, state: State<'_, OfflineState>, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.set_show_partial_playlists(enabled) } @@ -672,7 +706,8 @@ pub mod commands { enabled: bool, state: State<'_, OfflineState>, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.set_allow_cast_while_offline(enabled) } @@ -682,7 +717,8 @@ pub mod commands { enabled: bool, state: State<'_, OfflineState>, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.set_allow_immediate_scrobbling(enabled) } @@ -692,7 +728,8 @@ pub mod commands { enabled: bool, state: State<'_, OfflineState>, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.set_allow_accumulated_scrobbling(enabled) } @@ -702,7 +739,8 @@ pub mod commands { enabled: bool, state: State<'_, OfflineState>, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.set_show_network_folders_in_manual_offline(enabled) } @@ -724,7 +762,8 @@ pub mod commands { local_track_paths: Vec, state: State<'_, OfflineState>, ) -> Result { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.create_pending_playlist(&name, description.as_deref(), is_public, &track_ids, &local_track_paths) } @@ -733,7 +772,8 @@ pub mod commands { pub fn get_pending_playlists( state: State<'_, OfflineState>, ) -> Result, String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.get_pending_playlists() } @@ -745,7 +785,8 @@ pub mod commands { local_track_paths: Vec, state: State<'_, OfflineState>, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.add_tracks_to_pending_playlist(pending_id, &qobuz_track_ids, &local_track_paths) } @@ -754,7 +795,8 @@ pub mod commands { pub fn get_pending_playlist_count( state: State<'_, OfflineState>, ) -> Result { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.get_pending_playlist_count() } @@ -765,7 +807,8 @@ pub mod commands { qobuz_playlist_id: u64, state: State<'_, OfflineState>, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.update_qobuz_playlist_id(pending_id, qobuz_playlist_id) } @@ -776,7 +819,8 @@ pub mod commands { qobuz_playlist_id: u64, state: State<'_, OfflineState>, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.mark_playlist_synced(pending_id, qobuz_playlist_id) } @@ -786,7 +830,8 @@ pub mod commands { pending_id: i64, state: State<'_, OfflineState>, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.delete_pending_playlist(pending_id) } @@ -801,7 +846,8 @@ pub mod commands { timestamp: i64, state: State<'_, OfflineState>, ) -> Result { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.queue_scrobble(&artist, &track, album.as_deref(), timestamp) } @@ -811,7 +857,8 @@ pub mod commands { limit: Option, state: State<'_, OfflineState>, ) -> Result, String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.get_queued_scrobbles(limit.unwrap_or(50)) } @@ -821,7 +868,8 @@ pub mod commands { ids: Vec, state: State<'_, OfflineState>, ) -> Result<(), String> { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.mark_scrobbles_sent(&ids) } @@ -830,7 +878,8 @@ pub mod commands { pub fn get_queued_scrobble_count( state: State<'_, OfflineState>, ) -> Result { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.get_queued_scrobble_count() } @@ -840,7 +889,8 @@ pub mod commands { older_than_days: Option, state: State<'_, OfflineState>, ) -> Result { - let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let guard__ = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + let store = guard__.as_ref().ok_or("No active session - please log in")?; store.cleanup_sent_scrobbles(older_than_days.unwrap_or(7)) } } diff --git a/src-tauri/src/offline_cache/commands.rs b/src-tauri/src/offline_cache/commands.rs index 61dc91506..1c366cdf9 100644 --- a/src-tauri/src/offline_cache/commands.rs +++ b/src-tauri/src/offline_cache/commands.rs @@ -19,7 +19,7 @@ async fn post_process_cached_track( current_path: &str, offline_root: &str, qobuz_client: &crate::api::QobuzClient, - library_db: Arc>, + library_db: Arc>>, ) -> Result { log::info!("Post-processing cached track {}", track_id); @@ -79,7 +79,8 @@ async fn post_process_cached_track( }; // 7. ALWAYS insert into local library DB (visibility controlled by toggle) - let lib_guard = library_db.lock().await; + let lib_opt__ = library_db.lock().await; + let lib_guard = lib_opt__.as_ref().ok_or("No active session - please log in")?; // Generate album_group_key: album|album_artist let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist); @@ -147,7 +148,8 @@ pub async fn cache_track_for_offline( // Insert into database as queued { - let db = cache_state.db.lock().await; + let guard__ = cache_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; db.insert_track(&track_info, &file_path_str)?; } @@ -166,12 +168,13 @@ pub async fn cache_track_for_offline( Ok(permit) => permit, Err(err) => { log::error!("Failed to acquire cache slot for track {}: {}", track_id, err); - let db_guard = db.lock().await; - let _ = db_guard.update_status( - track_id, - OfflineCacheStatus::Failed, - Some("Failed to start caching"), - ); + if let Some(db_guard) = db.lock().await.as_ref() { + let _ = db_guard.update_status( + track_id, + 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" @@ -182,8 +185,9 @@ pub async fn cache_track_for_offline( // Update status to caching { - let db_guard = db.lock().await; - let _ = db_guard.update_status(track_id, OfflineCacheStatus::Downloading, None); + if let Some(db_guard) = db.lock().await.as_ref() { + let _ = db_guard.update_status(track_id, OfflineCacheStatus::Downloading, None); + } } let _ = app.emit("offline:caching_started", serde_json::json!({ @@ -202,12 +206,13 @@ pub async fn cache_track_for_offline( Ok(s) => s.url, Err(e) => { log::error!("Failed to get stream URL for track {}: {}", track_id, e); - let db_guard = db.lock().await; - let _ = db_guard.update_status( - track_id, - OfflineCacheStatus::Failed, - Some(&format!("Failed to get stream URL: {}", e)), - ); + if let Some(db_guard) = db.lock().await.as_ref() { + let _ = db_guard.update_status( + track_id, + 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() @@ -224,8 +229,9 @@ pub async fn cache_track_for_offline( Ok(size) => { log::info!("Caching complete for track {}: {} bytes", track_id, size); { - let db_guard = db.lock().await; - let _ = db_guard.mark_complete(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!({ @@ -247,9 +253,10 @@ pub async fn cache_track_for_offline( ).await { Ok(new_path) => { // Update database with new path - let db_guard = db.lock().await; - if let Err(e) = db_guard.update_file_path(track_id, &new_path) { - log::error!("Failed to update path for track {}: {}", track_id, e); + if let Some(db_guard) = db.lock().await.as_ref() { + if let Err(e) = db_guard.update_file_path(track_id, &new_path) { + log::error!("Failed to update path for track {}: {}", track_id, e); + } } let _ = app.emit("offline:caching_processed", serde_json::json!({ @@ -265,8 +272,9 @@ pub async fn cache_track_for_offline( } Err(e) => { log::error!("Caching failed for track {}: {}", track_id, e); - let db_guard = db.lock().await; - let _ = db_guard.update_status(track_id, OfflineCacheStatus::Failed, Some(&e)); + if let Some(db_guard) = db.lock().await.as_ref() { + let _ = db_guard.update_status(track_id, OfflineCacheStatus::Failed, Some(&e)); + } let _ = app.emit("offline:caching_failed", serde_json::json!({ "trackId": track_id, "error": e @@ -284,7 +292,8 @@ pub async fn is_track_cached( track_id: u64, cache_state: State<'_, OfflineCacheState>, ) -> Result { - let db = cache_state.db.lock().await; + let guard__ = cache_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; db.is_cached(track_id) } @@ -294,7 +303,8 @@ pub async fn get_cached_track_path( track_id: u64, cache_state: State<'_, OfflineCacheState>, ) -> Result, String> { - let db = cache_state.db.lock().await; + let guard__ = cache_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; db.get_file_path(track_id) } @@ -304,7 +314,8 @@ pub async fn get_cached_track( track_id: u64, cache_state: State<'_, OfflineCacheState>, ) -> Result, String> { - let db = cache_state.db.lock().await; + let guard__ = cache_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; db.get_track(track_id) } @@ -313,7 +324,8 @@ pub async fn get_cached_track( pub async fn get_cached_tracks( cache_state: State<'_, OfflineCacheState>, ) -> Result, String> { - let db = cache_state.db.lock().await; + let guard__ = cache_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; db.get_all_tracks() } @@ -322,8 +334,9 @@ pub async fn get_cached_tracks( pub async fn get_offline_cache_stats( cache_state: State<'_, OfflineCacheState>, ) -> Result { - let db = cache_state.db.lock().await; let limit = *cache_state.limit_bytes.lock().await; + let guard__ = cache_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; db.get_stats(&cache_state.get_cache_path(), limit) } @@ -336,26 +349,29 @@ pub async fn remove_cached_track( ) -> Result<(), String> { log::info!("Command: remove_cached_track {}", track_id); - let db = cache_state.db.lock().await; + { + let guard__ = cache_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; - // Get file path and delete from DB - if let Some(file_path) = db.delete_track(track_id)? { - // Delete the actual file - let path = std::path::Path::new(&file_path); - if path.exists() { - std::fs::remove_file(path) - .map_err(|e| format!("Failed to delete file: {}", e))?; - } + // Get file path and delete from DB + if let Some(file_path) = db.delete_track(track_id)? { + // Delete the actual file + let path = std::path::Path::new(&file_path); + if path.exists() { + std::fs::remove_file(path) + .map_err(|e| format!("Failed to delete file: {}", e))?; + } - // Clean up empty album folder (and artist folder if also empty) - if let Some(album_dir) = path.parent() { - cleanup_empty_folder(album_dir, &cache_state.cache_dir); + // Clean up empty album folder (and artist folder if also empty) + if let Some(album_dir) = path.parent() { + cleanup_empty_folder(album_dir, &cache_state.cache_dir); + } } } - drop(db); // Also remove from library if it was added - let library_db = library_state.db.lock().await; + let guard__ = library_state.db.lock().await; + let library_db = guard__.as_ref().ok_or("No active session - please log in")?; let _ = library_db.remove_qobuz_cached_track(track_id); Ok(()) @@ -413,11 +429,11 @@ pub async fn purge_all_cached_files( cache_state: &OfflineCacheState, library_state: &crate::library::commands::LibraryState, ) -> Result<(), String> { - let db = cache_state.db.lock().await; - - // Get all file paths and clear DB - let paths = db.clear_all()?; - drop(db); + 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 path in paths { @@ -458,7 +474,8 @@ pub async fn purge_all_cached_files( } // Remove all Qobuz cached tracks from library - let library_db = library_state.db.lock().await; + 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))?; @@ -515,7 +532,8 @@ pub async fn open_album_folder( ) -> Result<(), String> { log::info!("Command: open_album_folder for album_id: {}", album_id); - let db = cache_state.db.lock().await; + let guard__ = cache_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; // Get tracks for this album let tracks = db.get_all_tracks()?; @@ -556,7 +574,8 @@ pub async fn open_track_folder( ) -> Result<(), String> { log::info!("Command: open_track_folder for track_id: {}", track_id); - let db = cache_state.db.lock().await; + let guard__ = cache_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; // Get the track's file path let file_path = db.get_file_path(track_id)? @@ -583,7 +602,8 @@ pub async fn check_album_fully_cached( album_id: String, cache_state: State<'_, OfflineCacheState>, ) -> Result { - let db = cache_state.db.lock().await; + let guard__ = cache_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; // Get all tracks for this album let tracks = db.get_all_tracks()?; @@ -611,7 +631,8 @@ async fn evict_if_needed( cache_state: &OfflineCacheState, limit_bytes: u64, ) -> Result<(), String> { - let db = cache_state.db.lock().await; + let guard__ = cache_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; let stats = db.get_stats(&cache_state.get_cache_path(), Some(limit_bytes))?; if stats.total_size_bytes <= limit_bytes { @@ -746,47 +767,54 @@ pub async fn sync_offline_cache_to_library( ) -> Result { log::info!("Command: sync_offline_cache_to_library"); - let cache_db = cache_state.db.lock().await; - let library_db = library_state.db.lock().await; + // Get ready tracks from cache (scoped to drop lock before library access) + let ready_tracks = { + let guard__ = cache_state.db.lock().await; + let cache_db = guard__.as_ref().ok_or("No active session - please log in")?; + cache_db.get_ready_tracks_for_sync()? + }; - // Get all ready tracks from offline cache - let ready_tracks = cache_db.get_ready_tracks_for_sync()?; + let mut synced = 0u32; + let mut already_present = 0u32; + let mut errors = 0u32; - let mut synced = 0; - let mut already_present = 0; - let mut errors = 0; + // Process tracks with library lock (scoped) + { + let guard__ = library_state.db.lock().await; + let library_db = guard__.as_ref().ok_or("No active session - please log in")?; - for track in ready_tracks { - // Check if track already exists in library - match library_db.track_exists_by_qobuz_id(track.track_id) { - Ok(true) => { - already_present += 1; - } - Ok(false) => { - // Insert into library with basic metadata - match library_db.insert_qobuz_cached_track_direct( - track.track_id, - &track.title, - &track.artist, - track.album.as_deref(), - track.duration_secs, - &track.file_path, - track.bit_depth, - track.sample_rate, - ) { - Ok(_) => { - log::info!("Synced track {} to library: {}", track.track_id, track.title); - synced += 1; - } - Err(e) => { - log::error!("Failed to sync track {}: {}", track.track_id, e); - errors += 1; + for track in ready_tracks { + // Check if track already exists in library + match library_db.track_exists_by_qobuz_id(track.track_id) { + Ok(true) => { + already_present += 1; + } + Ok(false) => { + // Insert into library with basic metadata + match library_db.insert_qobuz_cached_track_direct( + track.track_id, + &track.title, + &track.artist, + track.album.as_deref(), + track.duration_secs, + &track.file_path, + track.bit_depth, + track.sample_rate, + ) { + Ok(_) => { + log::info!("Synced track {} to library: {}", track.track_id, track.title); + synced += 1; + } + Err(e) => { + log::error!("Failed to sync track {}: {}", track.track_id, e); + errors += 1; + } } } - } - Err(e) => { - log::error!("Failed to check if track {} exists: {}", track.track_id, e); - errors += 1; + Err(e) => { + log::error!("Failed to check if track {} exists: {}", track.track_id, e); + errors += 1; + } } } } diff --git a/src-tauri/src/offline_cache/migration.rs b/src-tauri/src/offline_cache/migration.rs index 91f41769e..b5726ef01 100644 --- a/src-tauri/src/offline_cache/migration.rs +++ b/src-tauri/src/offline_cache/migration.rs @@ -73,7 +73,7 @@ async fn migrate_single_track( legacy_path: PathBuf, offline_root: &str, qobuz_client: &QobuzClient, - library_db: Arc>, + library_db: Arc>>, ) -> Result { log::info!("Migrating track {}", track_id); @@ -128,7 +128,8 @@ async fn migrate_single_track( }; // 7. Insert into local library DB - let lib_guard = library_db.lock().await; + 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); @@ -162,7 +163,7 @@ pub async fn migrate_legacy_cached_files( tracks_dir: PathBuf, offline_root: String, qobuz_client: Arc>, - library_db: Arc>, + library_db: Arc>>, ) -> MigrationStatus { let total = track_ids.len(); let mut status = MigrationStatus { diff --git a/src-tauri/src/offline_cache/mod.rs b/src-tauri/src/offline_cache/mod.rs index 9d1065526..bbb560720 100644 --- a/src-tauri/src/offline_cache/mod.rs +++ b/src-tauri/src/offline_cache/mod.rs @@ -129,7 +129,7 @@ pub struct TrackCacheInfo { /// Offline cache state manager pub struct OfflineCacheState { - pub db: Arc>, + pub db: Arc>>, pub fetcher: Arc, pub cache_dir: PathBuf, /// Cache limit in bytes (None = unlimited) @@ -160,7 +160,7 @@ impl OfflineCacheState { let default_limit = Some(2 * 1024 * 1024 * 1024u64); let state = Self { - db: Arc::new(Mutex::new(db)), + db: Arc::new(Mutex::new(Some(db))), fetcher: Arc::new(StreamFetcher::new()), cache_dir: cache_dir.clone(), limit_bytes: Arc::new(Mutex::new(default_limit)), @@ -172,6 +172,41 @@ impl OfflineCacheState { 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, + limit_bytes: Arc::new(Mutex::new(Some(2 * 1024 * 1024 * 1024u64))), + cache_semaphore: Arc::new(Semaphore::new(3)), + } + } + + pub 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.blocking_lock(); + *guard = Some(new_db); + log::info!("Offline cache initialized at: {:?}", cache_dir); + Ok(()) + } + + pub fn teardown(&self) { + let mut guard = self.db.blocking_lock(); + *guard = None; + } + /// Get the path for a track's audio file pub fn track_file_path(&self, track_id: u64, format: &str) -> PathBuf { self.cache_dir.join("tracks").join(format!("{}.{}", track_id, format)) diff --git a/src-tauri/src/radio_engine/db.rs b/src-tauri/src/radio_engine/db.rs index faf62221d..38b91ff37 100644 --- a/src-tauri/src/radio_engine/db.rs +++ b/src-tauri/src/radio_engine/db.rs @@ -70,6 +70,14 @@ impl RadioDb { Self::open(&db_path) } + pub fn open_at(base_dir: &Path) -> Result { + let radio_dir = base_dir.join("radio"); + std::fs::create_dir_all(&radio_dir) + .map_err(|e| format!("Failed to create radio data directory: {}", e))?; + let db_path = radio_dir.join("radio_engine.db"); + Self::open(&db_path) + } + pub fn open(path: &Path) -> Result { let conn = Connection::open(path) .map_err(|e| format!("Failed to open radio database: {}", e))?; diff --git a/src-tauri/src/reco_store/commands.rs b/src-tauri/src/reco_store/commands.rs index 3b3701367..b2670c226 100644 --- a/src-tauri/src/reco_store/commands.rs +++ b/src-tauri/src/reco_store/commands.rs @@ -42,7 +42,8 @@ pub async fn reco_log_event( event.item_type.as_str() ); - let db = state.db.lock().await; + let guard__ = state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; db.insert_event(&event) } @@ -59,7 +60,8 @@ pub async fn reco_get_home( let limit_top_artists = limit_top_artists.unwrap_or(10); let limit_favorites = limit_favorites.unwrap_or(12); - let db = state.db.lock().await; + let guard__ = state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; let recently_played_album_ids = db.get_recent_album_ids(limit_recent_albums)?; let continue_listening_track_ids = db.get_recent_track_ids(limit_continue_tracks)?; @@ -92,7 +94,8 @@ pub async fn reco_train_scores( let now_ts = current_timestamp(); let since_ts = now_ts.saturating_sub(lookback_days * 86_400); - let mut db = state.db.lock().await; + let mut guard__ = state.db.lock().await; + let db = guard__.as_mut().ok_or("No active session - please log in")?; let events = db.get_events_since(since_ts, Some(max_events))?; let total_events = events.len(); let total_favorite_events = events @@ -148,7 +151,8 @@ pub async fn reco_get_home_ml( let limit_top_artists = limit_top_artists.unwrap_or(10); let limit_favorites = limit_favorites.unwrap_or(12); - let db = state.db.lock().await; + let guard__ = state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; let has_scores = db.has_scores("all")?; // HYBRID APPROACH: Always get fresh recent plays first, then supplement with scored @@ -402,7 +406,8 @@ pub async fn get_playlist_suggestions( state: State<'_, RecoState>, ) -> Result, String> { let limit = limit.unwrap_or(10); - let db = state.db.lock().await; + let guard__ = state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; // Get user's top scored tracks (get more than needed for filtering) let fetch_limit = (limit * 5).max(50); @@ -484,8 +489,12 @@ pub async fn reco_backfill_genres( let batch_size = batch_size.unwrap_or(50); log::info!("Command: reco_backfill_genres batch_size={}", batch_size); - let db = reco_state.db.lock().await; - let album_ids = db.get_album_ids_without_genre(batch_size)?; + // Get album IDs first (scoped lock to avoid Send issue) + let album_ids = { + let guard__ = reco_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; + db.get_album_ids_without_genre(batch_size)? + }; if album_ids.is_empty() { log::info!("No albums need genre backfill"); @@ -499,52 +508,48 @@ pub async fn reco_backfill_genres( log::info!("Backfilling genres for {} albums", album_ids.len()); - let client = app_state.client.lock().await; let mut albums_processed = 0u32; let mut events_updated = 0u64; let mut albums_failed = 0u32; for album_id in &album_ids { - // Fetch album from Qobuz API - match client.get_album(album_id).await { - Ok(album) => { - if let Some(genre) = album.genre { - // Update all events with this album_id - match db.update_genre_for_album(album_id, genre.id) { - Ok(updated) => { - events_updated += updated; - albums_processed += 1; - log::debug!( - "Updated {} events for album {} with genre {} ({})", - updated, - album_id, - genre.id, - genre.name - ); - } - Err(e) => { - log::warn!("Failed to update genre for album {}: {}", album_id, e); - albums_failed += 1; - } - } - } else { - // Album has no genre, mark as processed by setting genre_id to 0 - match db.update_genre_for_album(album_id, 0) { - Ok(updated) => { - events_updated += updated; - albums_processed += 1; - log::debug!("Album {} has no genre, set to 0", album_id); - } - Err(e) => { - log::warn!("Failed to update genre for album {}: {}", album_id, e); - albums_failed += 1; - } - } + // Fetch album from Qobuz API (lock client, then drop before DB) + let genre_info = { + let client = app_state.client.lock().await; + match client.get_album(album_id).await { + Ok(album) => Some(album.genre.map(|g| (g.id, g.name))), + Err(e) => { + log::warn!("Failed to fetch album {}: {}", album_id, e); + albums_failed += 1; + None } } - Err(e) => { - log::warn!("Failed to fetch album {}: {}", album_id, e); - albums_failed += 1; + }; + + if let Some(genre_opt) = genre_info { + let genre_id = genre_opt.as_ref().map(|(id, _)| *id).unwrap_or(0); + let genre_name = genre_opt.as_ref().map(|(_, name)| name.as_str()).unwrap_or("none"); + + // Re-acquire DB lock for update + let guard__ = reco_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; + match db.update_genre_for_album(album_id, genre_id) { + Ok(updated) => { + events_updated += updated; + albums_processed += 1; + if genre_id > 0 { + log::debug!( + "Updated {} events for album {} with genre {} ({})", + updated, album_id, genre_id, genre_name + ); + } else { + log::debug!("Album {} has no genre, set to 0", album_id); + } + } + Err(e) => { + log::warn!("Failed to update genre for album {}: {}", album_id, e); + albums_failed += 1; + } } } @@ -553,10 +558,15 @@ pub async fn reco_backfill_genres( } // Check how many albums still need backfill - let remaining = db.get_album_ids_without_genre(1)?; - let albums_remaining = if remaining.is_empty() { 0 } else { - // Get actual count - db.get_album_ids_without_genre(10000)?.len() as u32 + let albums_remaining = { + let guard__ = reco_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; + let remaining = db.get_album_ids_without_genre(1)?; + if remaining.is_empty() { + 0 + } else { + db.get_album_ids_without_genre(10000)?.len() as u32 + } }; log::info!( @@ -580,7 +590,8 @@ pub async fn reco_backfill_genres( pub async fn reco_needs_genre_backfill( reco_state: State<'_, RecoState>, ) -> Result { - let db = reco_state.db.lock().await; + let guard__ = reco_state.db.lock().await; + let db = guard__.as_ref().ok_or("No active session - please log in")?; let albums = db.get_album_ids_without_genre(1)?; Ok(!albums.is_empty()) } diff --git a/src-tauri/src/reco_store/mod.rs b/src-tauri/src/reco_store/mod.rs index e9dd48064..586bafbfe 100644 --- a/src-tauri/src/reco_store/mod.rs +++ b/src-tauri/src/reco_store/mod.rs @@ -6,6 +6,7 @@ pub mod commands; pub mod db; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::sync::Arc; use tokio::sync::Mutex; @@ -78,7 +79,7 @@ pub struct HomeSeeds { /// Recommendation store state shared across commands pub struct RecoState { - pub db: Arc>, + pub db: Arc>>, } impl RecoState { @@ -95,7 +96,29 @@ impl RecoState { let db = RecoStoreDb::new(&db_path)?; Ok(Self { - db: Arc::new(Mutex::new(db)), + db: Arc::new(Mutex::new(Some(db))), }) } + + pub fn new_empty() -> Self { + Self { + db: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let reco_dir = base_dir.join("reco"); + std::fs::create_dir_all(&reco_dir) + .map_err(|e| format!("Failed to create reco directory: {}", e))?; + let db_path = reco_dir.join("events.db"); + let new_db = RecoStoreDb::new(&db_path)?; + let mut guard = self.db.blocking_lock(); + *guard = Some(new_db); + Ok(()) + } + + pub fn teardown(&self) { + let mut guard = self.db.blocking_lock(); + *guard = None; + } } diff --git a/src-tauri/src/updates/mod.rs b/src-tauri/src/updates/mod.rs index a23d26cf9..f915e0c6c 100644 --- a/src-tauri/src/updates/mod.rs +++ b/src-tauri/src/updates/mod.rs @@ -10,6 +10,7 @@ use log::{info, warn}; use reqwest::Client; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -97,8 +98,19 @@ impl UpdatesStore { .map_err(|e| format!("Failed to create data directory: {}", e))?; let db_path = data_dir.join("updates.db"); + Self::open_at(&db_path) + } + + pub fn new_at(base_dir: &Path) -> Result { + std::fs::create_dir_all(base_dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + let db_path = base_dir.join("updates.db"); + Self::open_at(&db_path) + } + + fn open_at(db_path: &Path) -> Result { let conn = - Connection::open(&db_path).map_err(|e| format!("Failed to open updates database: {}", e))?; + Connection::open(db_path).map_err(|e| format!("Failed to open updates database: {}", e))?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS update_preferences ( @@ -262,27 +274,52 @@ impl UpdatesStore { /// Thread-safe state wrapper pub struct UpdatesState { - store: Arc>, + store: Arc>>, client: Client, } impl UpdatesState { pub fn new() -> Result { let store = UpdatesStore::new()?; - let client = Client::builder() - .timeout(Duration::from_secs(NETWORK_TIMEOUT_SECONDS)) - .user_agent("qbz-nix-updates") - .build() - .map_err(|e| format!("Failed to create updates HTTP client: {}", e))?; + let client = Self::build_client()?; Ok(Self { - store: Arc::new(Mutex::new(store)), + store: Arc::new(Mutex::new(Some(store))), client, }) } - fn with_store(&self, f: impl FnOnce(&UpdatesStore) -> T) -> T { + pub fn new_empty() -> Result { + let client = Self::build_client()?; + Ok(Self { + store: Arc::new(Mutex::new(None)), + client, + }) + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let new_store = UpdatesStore::new_at(base_dir)?; + let mut guard = self.store.lock().expect("UpdatesStore mutex poisoned"); + *guard = Some(new_store); + Ok(()) + } + + pub fn teardown(&self) { + let mut guard = self.store.lock().expect("UpdatesStore mutex poisoned"); + *guard = None; + } + + fn build_client() -> Result { + Client::builder() + .timeout(Duration::from_secs(NETWORK_TIMEOUT_SECONDS)) + .user_agent("qbz-nix-updates") + .build() + .map_err(|e| format!("Failed to create updates HTTP client: {}", e)) + } + + fn with_store(&self, f: impl FnOnce(&UpdatesStore) -> T) -> Result { let guard = self.store.lock().expect("UpdatesStore mutex poisoned"); - f(&guard) + let store = guard.as_ref().ok_or("No active session - please log in")?; + Ok(f(store)) } fn current_version(&self) -> String { @@ -411,7 +448,7 @@ fn select_latest_valid_update( } let is_blocked = state.with_store(|store| { store.is_release_acknowledged(&info.version) || store.is_release_ignored(&info.version) - }); + }).unwrap_or(false); if is_blocked { continue; } @@ -424,7 +461,7 @@ fn select_latest_valid_update( #[tauri::command] pub fn get_update_preferences(state: tauri::State) -> Result { - state.with_store(|store| store.get_preferences()) + state.with_store(|store| store.get_preferences())? } #[tauri::command] @@ -432,7 +469,7 @@ pub fn set_update_check_on_launch( enabled: bool, state: tauri::State, ) -> Result<(), String> { - state.with_store(|store| store.set_check_on_launch(enabled)) + state.with_store(|store| store.set_check_on_launch(enabled))? } #[tauri::command] @@ -440,47 +477,47 @@ pub fn set_show_whats_new_on_launch( enabled: bool, state: tauri::State, ) -> Result<(), String> { - state.with_store(|store| store.set_show_whats_new_on_launch(enabled)) + state.with_store(|store| store.set_show_whats_new_on_launch(enabled))? } #[tauri::command] pub fn acknowledge_release(version: String, state: tauri::State) -> Result<(), String> { - state.with_store(|store| store.acknowledge_release(&version)) + state.with_store(|store| store.acknowledge_release(&version))? } #[tauri::command] pub fn ignore_release(version: String, state: tauri::State) -> Result<(), String> { - state.with_store(|store| store.ignore_release(&version)) + state.with_store(|store| store.ignore_release(&version))? } #[tauri::command] -pub fn is_release_acknowledged(version: String, state: tauri::State) -> bool { +pub fn is_release_acknowledged(version: String, state: tauri::State) -> Result { state.with_store(|store| store.is_release_acknowledged(&version)) } #[tauri::command] -pub fn is_release_ignored(version: String, state: tauri::State) -> bool { +pub fn is_release_ignored(version: String, state: tauri::State) -> Result { state.with_store(|store| store.is_release_ignored(&version)) } #[tauri::command] -pub fn has_whats_new_been_shown(version: String, state: tauri::State) -> bool { +pub fn has_whats_new_been_shown(version: String, state: tauri::State) -> Result { state.with_store(|store| store.has_whats_new_been_shown(&version)) } #[tauri::command] pub fn mark_whats_new_shown(version: String, state: tauri::State) -> Result<(), String> { - state.with_store(|store| store.mark_whats_new_shown(&version)) + state.with_store(|store| store.mark_whats_new_shown(&version))? } #[tauri::command] -pub fn has_flatpak_welcome_been_shown(state: tauri::State) -> bool { +pub fn has_flatpak_welcome_been_shown(state: tauri::State) -> Result { state.with_store(|store| store.has_flatpak_welcome_been_shown()) } #[tauri::command] pub fn mark_flatpak_welcome_shown(state: tauri::State) -> Result<(), String> { - state.with_store(|store| store.mark_flatpak_welcome_shown()) + state.with_store(|store| store.mark_flatpak_welcome_shown())? } #[tauri::command] @@ -496,7 +533,7 @@ pub async fn check_for_updates( let current_version = state.current_version(); if mode == "launch" { - let prefs = match state.with_store(|store| store.get_preferences()) { + let prefs = match state.with_store(|store| store.get_preferences()).and_then(|r| r) { Ok(p) => p, Err(e) => { warn!("[Updates] Failed to read preferences: {}", e);