jellytau_lib/commands/
library.rs1use std::sync::Arc;
13
14use log::{debug, info, warn};
15use tauri::{Manager, State};
16
17use crate::commands::repository::RepositoryManagerWrapper;
18use crate::commands::storage::DatabaseWrapper;
19use crate::repository::exclusions;
20use crate::repository::types::{GetItemsOptions, SearchScope};
21use crate::repository::MediaRepository;
22use crate::settings::LibrarySettings;
23use crate::storage::db_service::{DatabaseService, Query, QueryParam};
24use crate::utils::lock::MutexSafe;
25
26const LIBRARY_SETTINGS_KEY: &str = "library_settings";
32
33const CANDIDATE_SCAN_LIMIT: usize = 500;
39
40#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub struct ExclusionCandidate {
51 pub id: String,
53 pub name: String,
55 pub library_name: String,
58 pub is_library: bool,
60}
61
62#[tauri::command]
70#[specta::specta]
71pub async fn library_get_settings() -> Result<LibrarySettings, String> {
72 Ok(LibrarySettings {
73 excluded_item_ids: exclusions::excluded_item_ids(),
74 })
75}
76
77#[tauri::command]
85#[specta::specta]
86pub async fn library_set_settings(
87 db: State<'_, DatabaseWrapper>,
88 settings: LibrarySettings,
89) -> Result<LibrarySettings, String> {
90 let sanitised = settings.sanitised();
91 exclusions::set_excluded_item_ids(&sanitised.excluded_item_ids);
92 persist_library_settings(&db, &sanitised).await;
93 info!(
94 "[Library] {} folder(s) hidden from browsing",
95 sanitised.excluded_item_ids.len()
96 );
97 Ok(sanitised)
98}
99
100#[tauri::command]
114#[specta::specta]
115pub async fn library_get_exclusion_candidates(
116 manager: State<'_, RepositoryManagerWrapper>,
117 handle: String,
118) -> Result<Vec<ExclusionCandidate>, String> {
119 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
120
121 let libraries = repo
122 .as_ref()
123 .get_libraries()
124 .await
125 .map_err(|e| format!("{:?}", e))?;
126
127 let mut candidates: Vec<ExclusionCandidate> = Vec::new();
128
129 for library in libraries {
130 if SearchScope::for_collection_type(&library.collection_type) != Some(SearchScope::Music) {
131 continue;
132 }
133
134 candidates.push(ExclusionCandidate {
135 id: library.id.clone(),
136 name: library.name.clone(),
137 library_name: library.name.clone(),
138 is_library: true,
139 });
140
141 let options = GetItemsOptions {
142 recursive: Some(false),
143 sort_by: Some("SortName".to_string()),
144 sort_order: Some("Ascending".to_string()),
145 limit: Some(CANDIDATE_SCAN_LIMIT),
146 ..Default::default()
147 };
148
149 match repo.get_items_unfiltered(&library.id, Some(options)).await {
150 Ok(result) => {
151 for item in result.items {
152 if !item.is_folder {
153 continue;
154 }
155 candidates.push(ExclusionCandidate {
156 id: item.id,
157 name: item.name,
158 library_name: library.name.clone(),
159 is_library: false,
160 });
161 }
162 }
163 Err(e) => {
164 warn!(
167 "[Library] Could not list folders in {}: {:?}",
168 library.name, e
169 );
170 }
171 }
172 }
173
174 debug!("[Library] {} exclusion candidate(s)", candidates.len());
175 Ok(candidates)
176}
177
178async fn persist_library_settings(db: &State<'_, DatabaseWrapper>, settings: &LibrarySettings) {
186 let db_service = {
187 let database = db.0.lock_safe();
188 Arc::new(database.service())
189 };
190
191 let encoded = match serde_json::to_string(settings) {
192 Ok(value) => value,
193 Err(e) => {
194 warn!("[Library] Failed to encode library settings: {}", e);
195 return;
196 }
197 };
198
199 let query = Query::with_params(
200 "INSERT OR REPLACE INTO app_settings (key, value, updated_at)
201 VALUES (?, ?, CURRENT_TIMESTAMP)",
202 vec![
203 QueryParam::String(LIBRARY_SETTINGS_KEY.to_string()),
204 QueryParam::String(encoded),
205 ],
206 );
207
208 if let Err(e) = db_service.execute(query).await {
209 warn!("[Library] Failed to persist library settings: {}", e);
210 }
211}
212
213pub async fn restore_library_settings(app: &tauri::AppHandle) {
222 let db_service = {
223 let Some(db) = app.try_state::<DatabaseWrapper>() else {
224 warn!("[Library] No database available; nothing hidden from browsing");
225 return;
226 };
227 let database = db.0.lock_safe();
228 Arc::new(database.service())
229 };
230
231 let query = Query::with_params(
232 "SELECT value FROM app_settings WHERE key = ?",
233 vec![QueryParam::String(LIBRARY_SETTINGS_KEY.to_string())],
234 );
235
236 let stored: Option<String> = match db_service.query_optional(query, |row| row.get(0)).await {
237 Ok(value) => value,
238 Err(e) => {
239 warn!("[Library] Failed to read library settings: {}", e);
240 return;
241 }
242 };
243
244 let Some(stored) = stored else { return };
245 let settings: LibrarySettings = match serde_json::from_str(&stored) {
246 Ok(settings) => settings,
247 Err(e) => {
248 warn!(
249 "[Library] Ignoring unreadable persisted library settings {:?}: {}",
250 stored, e
251 );
252 return;
253 }
254 };
255
256 let settings = settings.sanitised();
257 exclusions::set_excluded_item_ids(&settings.excluded_item_ids);
258 if !settings.excluded_item_ids.is_empty() {
259 info!(
260 "[Library] Restored {} hidden folder(s)",
261 settings.excluded_item_ids.len()
262 );
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
276 fn test_library_settings_round_trip_through_json() {
277 let settings = LibrarySettings {
278 excluded_item_ids: vec!["folder-1".to_string(), "folder-2".to_string()],
279 };
280
281 let json = serde_json::to_string(&settings).expect("serialises");
282 assert!(
283 json.contains("\"excludedItemIds\""),
284 "camelCase on the wire"
285 );
286
287 let parsed: LibrarySettings = serde_json::from_str(&json).expect("parses back");
288 assert_eq!(parsed, settings);
289 }
290
291 #[test]
297 fn test_library_settings_default_hides_nothing() {
298 let parsed: LibrarySettings = serde_json::from_str("{}").expect("parses");
299 assert!(parsed.excluded_item_ids.is_empty());
300 assert!(LibrarySettings::default().excluded_item_ids.is_empty());
301 }
302
303 #[test]
309 fn test_library_settings_sanitised() {
310 let settings = LibrarySettings {
311 excluded_item_ids: vec![
312 " folder-1 ".to_string(),
313 "".to_string(),
314 " ".to_string(),
315 "folder-1".to_string(),
316 "folder-2".to_string(),
317 ],
318 }
319 .sanitised();
320
321 assert_eq!(
322 settings.excluded_item_ids,
323 vec!["folder-1".to_string(), "folder-2".to_string()]
324 );
325 }
326}