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}