//! Library browsing preferences — currently, which folders are hidden. //! //! The setting replaces a hardcoded frontend filter that dropped any item //! literally named "Podcasts", which was one user's folder layout keyed on an //! English string and shipped to everyone. What is hidden is now a user choice //! made of stable ids, applied in the repository layer //! (`repository::exclusions`) so every query path agrees; the frontend only //! renders a picker over the candidates this module serves. //! //! TRACES: UR-076 | DR-209 use std::sync::Arc; use log::{debug, info, warn}; use tauri::{Manager, State}; use crate::commands::repository::RepositoryManagerWrapper; use crate::commands::storage::DatabaseWrapper; use crate::repository::exclusions; use crate::repository::types::{GetItemsOptions, SearchScope}; use crate::repository::MediaRepository; use crate::settings::LibrarySettings; use crate::storage::db_service::{DatabaseService, Query, QueryParam}; use crate::utils::lock::MutexSafe; /// `app_settings` key holding the persisted library preferences (JSON). /// /// Persisted for the same reason the streaming cap is: a hidden folder that /// silently comes back on the next launch is a setting the user has to keep /// re-applying, and they would have no way to tell it had been forgotten. const LIBRARY_SETTINGS_KEY: &str = "library_settings"; /// How many immediate children of a library the picker will consider. /// /// A music library's root listing is folders and (on some layouts) artists, not /// the whole catalog, so this is generous. It exists to stop a pathological /// library from turning the settings page into an unbounded fetch. const CANDIDATE_SCAN_LIMIT: usize = 500; /// Something the user may choose to hide: a library, or a folder directly /// inside one. /// /// Which containers are *offerable* is a domain question (it depends on the /// library's Jellyfin collection type and on what counts as a folder), so the /// list is assembled here and the frontend renders it verbatim. /// /// TRACES: UR-076 | DR-209 #[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExclusionCandidate { /// Stable Jellyfin item id — what gets stored when the user picks it. pub id: String, /// Display name of the folder (or of the library, for a whole-library entry). pub name: String, /// Library this candidate lives in, so the picker can group and disambiguate /// two folders that share a name. pub library_name: String, /// True when the candidate *is* a library rather than a folder inside one. pub is_library: bool, } /// The library preferences currently in force. /// /// Read from the in-memory exclusion set rather than the database: that set is /// what queries actually consult, so reading it is the only answer that cannot /// disagree with what the user is seeing. /// /// TRACES: UR-076 | DR-209 #[tauri::command] #[specta::specta] pub async fn library_get_settings() -> Result { Ok(LibrarySettings { excluded_item_ids: exclusions::excluded_item_ids(), }) } /// Replace the library preferences: apply them to every subsequent query and /// persist them. /// /// Returns the sanitised value actually applied, so the picker shows what was /// stored rather than what it sent. /// /// TRACES: UR-076 | DR-209 #[tauri::command] #[specta::specta] pub async fn library_set_settings( db: State<'_, DatabaseWrapper>, settings: LibrarySettings, ) -> Result { let sanitised = settings.sanitised(); exclusions::set_excluded_item_ids(&sanitised.excluded_item_ids); persist_library_settings(&db, &sanitised).await; info!( "[Library] {} folder(s) hidden from browsing", sanitised.excluded_item_ids.len() ); Ok(sanitised) } /// The folders the user may choose to hide. /// /// Offers each music library and the folders directly inside it. Music is the /// only scope offered because it is the one where a foreign folder — podcasts, /// audiobooks, sound effects — routinely shares a library with the media the /// user actually browses; the scope is decided here rather than in the UI so the /// collection-type table stays out of the frontend /// (see `SearchScope::for_collection_type`). /// /// Reads through `HybridRepository::get_items_unfiltered` so folders that are /// *already* hidden still appear — otherwise the setting could never be undone. /// /// TRACES: UR-076 | DR-209 #[tauri::command] #[specta::specta] pub async fn library_get_exclusion_candidates( manager: State<'_, RepositoryManagerWrapper>, handle: String, ) -> Result, String> { let repo = manager.0.get(&handle).ok_or("Repository not found")?; let libraries = repo .as_ref() .get_libraries() .await .map_err(|e| format!("{:?}", e))?; let mut candidates: Vec = Vec::new(); for library in libraries { if SearchScope::for_collection_type(&library.collection_type) != Some(SearchScope::Music) { continue; } candidates.push(ExclusionCandidate { id: library.id.clone(), name: library.name.clone(), library_name: library.name.clone(), is_library: true, }); let options = GetItemsOptions { recursive: Some(false), sort_by: Some("SortName".to_string()), sort_order: Some("Ascending".to_string()), limit: Some(CANDIDATE_SCAN_LIMIT), ..Default::default() }; match repo.get_items_unfiltered(&library.id, Some(options)).await { Ok(result) => { for item in result.items { if !item.is_folder { continue; } candidates.push(ExclusionCandidate { id: item.id, name: item.name, library_name: library.name.clone(), is_library: false, }); } } Err(e) => { // One unreachable library must not cost the user the picker for // the others — an empty section is recoverable, an error is not. warn!( "[Library] Could not list folders in {}: {:?}", library.name, e ); } } } debug!("[Library] {} exclusion candidate(s)", candidates.len()); Ok(candidates) } /// Write the preferences to `app_settings`. /// /// Failure is logged, not returned: the setting has already been applied in /// memory, and failing the whole call because the write failed would leave the /// picker showing a state that *is* in force. /// /// TRACES: UR-076 | DR-209 async fn persist_library_settings(db: &State<'_, DatabaseWrapper>, settings: &LibrarySettings) { let db_service = { let database = db.0.lock_safe(); Arc::new(database.service()) }; let encoded = match serde_json::to_string(settings) { Ok(value) => value, Err(e) => { warn!("[Library] Failed to encode library settings: {}", e); return; } }; let query = Query::with_params( "INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", vec![ QueryParam::String(LIBRARY_SETTINGS_KEY.to_string()), QueryParam::String(encoded), ], ); if let Err(e) = db_service.execute(query).await { warn!("[Library] Failed to persist library settings: {}", e); } } /// Restore the persisted preferences at startup, into the exclusion set the /// repository consults. /// /// Called from the Tauri `setup` hook. A missing or unreadable row leaves the /// default — nothing hidden — in place, so a database problem shows the user /// more than they asked for rather than less. /// /// TRACES: UR-076 | DR-209 pub async fn restore_library_settings(app: &tauri::AppHandle) { let db_service = { let Some(db) = app.try_state::() else { warn!("[Library] No database available; nothing hidden from browsing"); return; }; let database = db.0.lock_safe(); Arc::new(database.service()) }; let query = Query::with_params( "SELECT value FROM app_settings WHERE key = ?", vec![QueryParam::String(LIBRARY_SETTINGS_KEY.to_string())], ); let stored: Option = match db_service.query_optional(query, |row| row.get(0)).await { Ok(value) => value, Err(e) => { warn!("[Library] Failed to read library settings: {}", e); return; } }; let Some(stored) = stored else { return }; let settings: LibrarySettings = match serde_json::from_str(&stored) { Ok(settings) => settings, Err(e) => { warn!( "[Library] Ignoring unreadable persisted library settings {:?}: {}", stored, e ); return; } }; let settings = settings.sanitised(); exclusions::set_excluded_item_ids(&settings.excluded_item_ids); if !settings.excluded_item_ids.is_empty() { info!( "[Library] Restored {} hidden folder(s)", settings.excluded_item_ids.len() ); } } #[cfg(test)] mod tests { use super::*; /// The persisted form must round-trip through the same camelCase JSON the /// IPC boundary uses — a rename here silently un-hides every folder the user /// chose, with no setting having been changed. /// /// TRACES: UR-076 | DR-209 | UT-203 #[test] fn test_library_settings_round_trip_through_json() { let settings = LibrarySettings { excluded_item_ids: vec!["folder-1".to_string(), "folder-2".to_string()], }; let json = serde_json::to_string(&settings).expect("serialises"); assert!(json.contains("\"excludedItemIds\""), "camelCase on the wire"); let parsed: LibrarySettings = serde_json::from_str(&json).expect("parses back"); assert_eq!(parsed, settings); } /// Settings persisted before this feature existed — and a row with the key /// missing entirely — must load as "nothing hidden", never as an error the /// caller has to handle or a default that hides something. /// /// TRACES: UR-076 | DR-209 | UT-203 #[test] fn test_library_settings_default_hides_nothing() { let parsed: LibrarySettings = serde_json::from_str("{}").expect("parses"); assert!(parsed.excluded_item_ids.is_empty()); assert!(LibrarySettings::default().excluded_item_ids.is_empty()); } /// Blank and duplicate ids are dropped on the way in, so a half-written or /// hand-edited value cannot grow the list without bound or store an id that /// matches nothing yet still shows as a selection. /// /// TRACES: UR-076 | DR-209 | UT-203 #[test] fn test_library_settings_sanitised() { let settings = LibrarySettings { excluded_item_ids: vec![ " folder-1 ".to_string(), "".to_string(), " ".to_string(), "folder-1".to_string(), "folder-2".to_string(), ], } .sanitised(); assert_eq!( settings.excluded_item_ids, vec!["folder-1".to_string(), "folder-2".to_string()] ); } }