Skip to main content

jellytau_lib/commands/
library.rs

1//! Library browsing preferences — currently, which folders are hidden.
2//!
3//! The setting replaces a hardcoded frontend filter that dropped any item
4//! literally named "Podcasts", which was one user's folder layout keyed on an
5//! English string and shipped to everyone. What is hidden is now a user choice
6//! made of stable ids, applied in the repository layer
7//! (`repository::exclusions`) so every query path agrees; the frontend only
8//! renders a picker over the candidates this module serves.
9//!
10//! TRACES: UR-076 | DR-209
11
12use 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
26/// `app_settings` key holding the persisted library preferences (JSON).
27///
28/// Persisted for the same reason the streaming cap is: a hidden folder that
29/// silently comes back on the next launch is a setting the user has to keep
30/// re-applying, and they would have no way to tell it had been forgotten.
31const LIBRARY_SETTINGS_KEY: &str = "library_settings";
32
33/// How many immediate children of a library the picker will consider.
34///
35/// A music library's root listing is folders and (on some layouts) artists, not
36/// the whole catalog, so this is generous. It exists to stop a pathological
37/// library from turning the settings page into an unbounded fetch.
38const CANDIDATE_SCAN_LIMIT: usize = 500;
39
40/// Something the user may choose to hide: a library, or a folder directly
41/// inside one.
42///
43/// Which containers are *offerable* is a domain question (it depends on the
44/// library's Jellyfin collection type and on what counts as a folder), so the
45/// list is assembled here and the frontend renders it verbatim.
46///
47/// TRACES: UR-076 | DR-209
48#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub struct ExclusionCandidate {
51    /// Stable Jellyfin item id — what gets stored when the user picks it.
52    pub id: String,
53    /// Display name of the folder (or of the library, for a whole-library entry).
54    pub name: String,
55    /// Library this candidate lives in, so the picker can group and disambiguate
56    /// two folders that share a name.
57    pub library_name: String,
58    /// True when the candidate *is* a library rather than a folder inside one.
59    pub is_library: bool,
60}
61
62/// The library preferences currently in force.
63///
64/// Read from the in-memory exclusion set rather than the database: that set is
65/// what queries actually consult, so reading it is the only answer that cannot
66/// disagree with what the user is seeing.
67///
68/// TRACES: UR-076 | DR-209
69#[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/// Replace the library preferences: apply them to every subsequent query and
78/// persist them.
79///
80/// Returns the sanitised value actually applied, so the picker shows what was
81/// stored rather than what it sent.
82///
83/// TRACES: UR-076 | DR-209
84#[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/// The folders the user may choose to hide.
101///
102/// Offers each music library and the folders directly inside it. Music is the
103/// only scope offered because it is the one where a foreign folder — podcasts,
104/// audiobooks, sound effects — routinely shares a library with the media the
105/// user actually browses; the scope is decided here rather than in the UI so the
106/// collection-type table stays out of the frontend
107/// (see `SearchScope::for_collection_type`).
108///
109/// Reads through `HybridRepository::get_items_unfiltered` so folders that are
110/// *already* hidden still appear — otherwise the setting could never be undone.
111///
112/// TRACES: UR-076 | DR-209
113#[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                // One unreachable library must not cost the user the picker for
165                // the others — an empty section is recoverable, an error is not.
166                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
178/// Write the preferences to `app_settings`.
179///
180/// Failure is logged, not returned: the setting has already been applied in
181/// memory, and failing the whole call because the write failed would leave the
182/// picker showing a state that *is* in force.
183///
184/// TRACES: UR-076 | DR-209
185async 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
213/// Restore the persisted preferences at startup, into the exclusion set the
214/// repository consults.
215///
216/// Called from the Tauri `setup` hook. A missing or unreadable row leaves the
217/// default — nothing hidden — in place, so a database problem shows the user
218/// more than they asked for rather than less.
219///
220/// TRACES: UR-076 | DR-209
221pub 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    /// The persisted form must round-trip through the same camelCase JSON the
271    /// IPC boundary uses — a rename here silently un-hides every folder the user
272    /// chose, with no setting having been changed.
273    ///
274    /// TRACES: UR-076 | DR-209 | UT-203
275    #[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    /// Settings persisted before this feature existed — and a row with the key
292    /// missing entirely — must load as "nothing hidden", never as an error the
293    /// caller has to handle or a default that hides something.
294    ///
295    /// TRACES: UR-076 | DR-209 | UT-203
296    #[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    /// Blank and duplicate ids are dropped on the way in, so a half-written or
304    /// hand-edited value cannot grow the list without bound or store an id that
305    /// matches nothing yet still shows as a selection.
306    ///
307    /// TRACES: UR-076 | DR-209 | UT-203
308    #[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}