feat(library): add per-user Library tab preferences store

Mirrors the FavoritesPreferences pattern so the Local Library can have
a user-configurable tab order and hidden-tabs list, persisted per user
in library_preferences.db. Adds the LibraryPreferences struct, the
single-row SQLite table, the LibraryPreferencesState lifecycle, and
the V2 get/save commands.

Default order matches the current LocalLibraryView tab list with the
renamed 'folders' tab in place of the old 'albums' tab. Phase 3 will
add 'albums' (the new metadata-grouped view) to this default.

Frontend wiring lands in the next commit on this branch.

cc
This commit is contained in:
vicrodh committed 2026-05-06 16:50:27 -06:00
1 parent 74289fb682
commit a5ab9965d0
6 files changed
+219

No files matched your search

+21
View File
@@ -3,6 +3,7 @@ use tauri::State;
use crate::audio::{AlsaPlugin, AudioBackendType, AudioDevice, BackendManager};
use crate::cache::CacheStats;
use crate::config::favorites_preferences::FavoritesPreferences;
use crate::config::library_preferences::LibraryPreferences;
use crate::config::playback_preferences::{
AutoplayMode, PlaybackPreferences, PlaybackPreferencesState,
};
@@ -190,6 +191,26 @@ pub fn v2_save_favorites_preferences(
crate::config::favorites_preferences::save_favorites_preferences(prefs, state)
}
#[tauri::command]
pub fn v2_get_library_preferences(
state: State<'_, crate::config::library_preferences::LibraryPreferencesState>,
) -> Result<LibraryPreferences, String> {
let guard = state
.store
.lock()
.map_err(|_| "Failed to lock library preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_preferences()
}
#[tauri::command]
pub fn v2_save_library_preferences(
prefs: LibraryPreferences,
state: State<'_, crate::config::library_preferences::LibraryPreferencesState>,
) -> Result<LibraryPreferences, String> {
crate::config::library_preferences::save_library_preferences(prefs, state)
}
#[tauri::command]
pub fn v2_get_cache_stats(state: State<'_, AppState>) -> CacheStats {
state.audio_cache.stats()
+177
View File
@@ -0,0 +1,177 @@
use rusqlite::{params, Connection, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LibraryPreferences {
pub tab_order: Vec<String>,
pub hidden_tabs: Vec<String>,
}
impl Default for LibraryPreferences {
fn default() -> Self {
Self {
// Default order matches the current LocalLibraryView tab list,
// with the renamed 'folders' tab in place of the old 'albums'
// tab. Phase 3 adds 'albums' (the new metadata-grouped view)
// to this default.
tab_order: vec![
"tracks".to_string(),
"folders".to_string(),
"artists".to_string(),
],
hidden_tabs: vec![],
}
}
}
pub struct LibraryPreferencesStore {
conn: Connection,
}
impl LibraryPreferencesStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open library preferences database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to set WAL mode on library preferences: {}", e))?;
conn.execute(
"CREATE TABLE IF NOT EXISTS library_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
tab_order TEXT NOT NULL,
hidden_tabs TEXT NOT NULL DEFAULT '[]'
)",
[],
)
.map_err(|e| format!("Failed to create library preferences table: {}", e))?;
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "library_preferences.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "library_preferences.db")
}
pub fn get_preferences(&self) -> Result<LibraryPreferences, String> {
let mut stmt = self
.conn
.prepare("SELECT tab_order, hidden_tabs FROM library_preferences WHERE id = 1")
.map_err(|e| format!("Failed to prepare select: {}", e))?;
let result = stmt.query_row([], |row| {
let tab_order_str: String = row.get(0)?;
let hidden_tabs_str: String = row.get(1)?;
let tab_order: Vec<String> = serde_json::from_str(&tab_order_str)
.unwrap_or_else(|_| LibraryPreferences::default().tab_order);
let hidden_tabs: Vec<String> =
serde_json::from_str(&hidden_tabs_str).unwrap_or_default();
Ok(LibraryPreferences {
tab_order,
hidden_tabs,
})
});
match result {
Ok(prefs) => Ok(prefs),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(LibraryPreferences::default()),
Err(e) => Err(format!("Failed to query library preferences: {}", e)),
}
}
pub fn save_preferences(
&self,
prefs: LibraryPreferences,
) -> Result<LibraryPreferences, String> {
let tab_order_str = serde_json::to_string(&prefs.tab_order)
.map_err(|e| format!("Failed to serialize tab_order: {}", e))?;
let hidden_tabs_str = serde_json::to_string(&prefs.hidden_tabs)
.map_err(|e| format!("Failed to serialize hidden_tabs: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO library_preferences (id, tab_order, hidden_tabs)
VALUES (1, ?1, ?2)",
params![tab_order_str, hidden_tabs_str],
)
.map_err(|e| format!("Failed to save library preferences: {}", e))?;
Ok(prefs)
}
}
pub struct LibraryPreferencesState {
pub store: Arc<Mutex<Option<LibraryPreferencesStore>>>,
}
impl LibraryPreferencesState {
pub fn new() -> Result<Self, String> {
let store = LibraryPreferencesStore::new()?;
Ok(Self {
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 = LibraryPreferencesStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock library 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 library preferences store".to_string())?;
*guard = None;
Ok(())
}
}
#[tauri::command]
pub fn get_library_preferences(
state: tauri::State<LibraryPreferencesState>,
) -> Result<LibraryPreferences, String> {
let guard = state
.store
.lock()
.map_err(|_| "Failed to lock library preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_preferences()
}
#[tauri::command]
pub fn save_library_preferences(
prefs: LibraryPreferences,
state: tauri::State<LibraryPreferencesState>,
) -> Result<LibraryPreferences, String> {
let guard = state
.store
.lock()
.map_err(|_| "Failed to lock library preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.save_preferences(prefs)
}
+6
View File
@@ -17,6 +17,7 @@ pub mod favorites_preferences;
pub mod graphics_settings;
pub mod image_cache_settings;
pub mod legal_settings;
pub mod library_preferences;
pub mod playback_preferences;
pub mod remote_control_settings;
pub mod subscription_state;
@@ -43,6 +44,11 @@ pub use favorites_preferences::{
FavoritesPreferencesState,
};
pub use library_preferences::{
get_library_preferences, save_library_preferences, LibraryPreferences,
LibraryPreferencesState,
};
pub use subscription_state::{
create_empty_subscription_state, create_subscription_state, SubscriptionState,
SubscriptionStateState, SubscriptionStateStore,
+5
View File
@@ -704,6 +704,8 @@ pub fn run(qconnect_cli_override: Option<bool>) {
let playback_prefs_state = config::playback_preferences::PlaybackPreferencesState::new_empty();
let favorites_prefs_state =
config::favorites_preferences::FavoritesPreferencesState::new_empty();
let library_prefs_state =
config::library_preferences::LibraryPreferencesState::new_empty();
let favorites_cache_state = config::favorites_cache::FavoritesCacheState::new_empty();
let tray_settings_state = config::tray_settings::TraySettingsState::new_empty();
let remote_control_settings_state =
@@ -1352,6 +1354,7 @@ pub fn run(qconnect_cli_override: Option<bool>) {
.manage(offline_state)
.manage(playback_prefs_state)
.manage(favorites_prefs_state)
.manage(library_prefs_state)
.manage(favorites_cache_state)
.manage(tray_settings_state)
.manage(remote_control_settings_state)
@@ -1452,6 +1455,8 @@ pub fn run(qconnect_cli_override: Option<bool>) {
commands_v2::v2_get_playback_preferences,
commands_v2::v2_get_favorites_preferences,
commands_v2::v2_save_favorites_preferences,
commands_v2::v2_get_library_preferences,
commands_v2::v2_save_library_preferences,
commands_v2::v2_get_cache_stats,
commands_v2::v2_get_available_backends,
commands_v2::v2_get_devices_for_backend,
+1
View File
@@ -16,6 +16,7 @@ const DATA_DIR_DB_FILES: &[&str] = &[
"subscription_state.db",
"playback_preferences.db",
"favorites_preferences.db",
"library_preferences.db",
"download_settings.db",
"audio_settings.db",
"tray_settings.db",
+9
View File
@@ -61,6 +61,8 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<()
app.state::<crate::config::playback_preferences::PlaybackPreferencesState>();
let favorites_prefs =
app.state::<crate::config::favorites_preferences::FavoritesPreferencesState>();
let library_prefs =
app.state::<crate::config::library_preferences::LibraryPreferencesState>();
let download_settings = app.state::<crate::config::download_settings::DownloadSettingsState>();
let audio_settings = app.state::<crate::config::audio_settings::AudioSettingsState>();
let tray_settings = app.state::<crate::config::tray_settings::TraySettingsState>();
@@ -114,6 +116,7 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<()
favorites_cache.init_at(&data_dir)?;
playback_prefs.init_at(&data_dir)?;
favorites_prefs.init_at(&data_dir)?;
library_prefs.init_at(&data_dir)?;
audio_settings.init_at(&data_dir)?;
// Sync per-user audio settings to CoreBridge player immediately.
@@ -305,6 +308,8 @@ pub async fn deactivate_session(app: &tauri::AppHandle) -> Result<(), String> {
app.state::<crate::config::playback_preferences::PlaybackPreferencesState>();
let favorites_prefs =
app.state::<crate::config::favorites_preferences::FavoritesPreferencesState>();
let library_prefs =
app.state::<crate::config::library_preferences::LibraryPreferencesState>();
let download_settings = app.state::<crate::config::download_settings::DownloadSettingsState>();
let audio_settings = app.state::<crate::config::audio_settings::AudioSettingsState>();
let tray_settings = app.state::<crate::config::tray_settings::TraySettingsState>();
@@ -336,6 +341,7 @@ pub async fn deactivate_session(app: &tauri::AppHandle) -> Result<(), String> {
favorites_cache.teardown()?;
playback_prefs.teardown()?;
favorites_prefs.teardown()?;
library_prefs.teardown()?;
audio_settings.teardown()?;
tray_settings.teardown()?;
remote_control_settings.teardown()?;
@@ -428,6 +434,8 @@ pub async fn activate_offline_session(app: &tauri::AppHandle) -> Result<(), Stri
let tray_settings = app.state::<crate::config::tray_settings::TraySettingsState>();
let favorites_prefs =
app.state::<crate::config::favorites_preferences::FavoritesPreferencesState>();
let library_prefs =
app.state::<crate::config::library_preferences::LibraryPreferencesState>();
library.init_at(&data_dir).await?;
@@ -450,6 +458,7 @@ pub async fn activate_offline_session(app: &tauri::AppHandle) -> Result<(), Stri
session_store.init_at(&data_dir)?;
favorites_cache.init_at(&data_dir)?;
favorites_prefs.init_at(&data_dir)?;
library_prefs.init_at(&data_dir)?;
tray_settings.init_at(&data_dir)?;
// Download settings — needed for "Show in Local Library" toggle