Skip to main content

#
jellytau_lib/repository/
exclusions.rs

    1//! Library folders the user has chosen to keep out of browsing.
    2//!
    3//! Some people file things inside a library that they never want to see while
    4//! browsing it — a "Podcasts" folder sitting in the music library is the
    5//! canonical case: its albums and tracks leak into album, artist, track and
    6//! playlist listings even though the user thinks of them as a different medium.
    7//!
    8//! This is a *domain* rule, not a presentation one: what an item belongs to, and
    9//! therefore whether a query should return it, is decided here in the repository
    10//! layer so every query path agrees. The predecessor of this module was a
    11//! frontend filter that dropped anything literally named "Podcasts" — one user's
    12//! folder layout, keyed on an English string, shipped to everyone. Excluding by
    13//! **id** instead of name is what makes the setting survive a rename, a
    14//! translation, or two folders sharing a name.
    15//!
    16//! The excluded set is process-wide rather than a field on a repository for the
    17//! same reason as `online::STREAMING_QUALITY`: it is a preference about *this
    18//! user's browsing*, not about a server session, so it must survive a repository
    19//! being rebuilt on re-login. It is written by the settings command and restored
    20//! from the database at startup.
    21//!
    22//! TRACES: UR-076 | DR-209
    23
    24use std::collections::HashSet;
    25use std::sync::RwLock;
    26
    27use super::types::{MediaItem, SearchResult};
    28use crate::utils::lock::RwLockSafe;
    29
    30/// Ids (normalised — see [`normalise_id`]) of items the user has hidden.
    31///
    32/// Empty by default: nobody inherits somebody else's folder layout.
    33///
    34/// TRACES: UR-076 | DR-209
    35static EXCLUDED_IDS: RwLock<Vec<String>> = RwLock::new(Vec::new());
    36
    37/// Jellyfin writes the same GUID both dashed and undashed depending on the
    38/// endpoint, and ids arriving over IPC may carry stray whitespace. Comparing a
    39/// canonical form means a stored id keeps matching whichever spelling a query
    40/// happens to return.
    41fn normalise_id(id: &str) -> String {
    42    id.trim().replace('-', "").to_ascii_lowercase()
    43}
    44
    45/// Replace the excluded set. Ids are normalised, de-duplicated and blanks
    46/// dropped, so a malformed value can never hide more than it names.
    47///
    48/// TRACES: UR-076 | DR-209
    49pub fn set_excluded_item_ids(ids: &[String]) {
    50    let mut normalised: Vec<String> = Vec::with_capacity(ids.len());
    51    for id in ids {
    52        let id = normalise_id(id);
    53        if id.is_empty() || normalised.contains(&id) {
    54            continue;
    55        }
    56        normalised.push(id);
    57    }
    58    *EXCLUDED_IDS.write_safe() = normalised;
    59}
    60
    61/// The excluded set as currently applied, normalised.
    62///
    63/// TRACES: UR-076 | DR-209
    64pub fn excluded_item_ids() -> Vec<String> {
    65    EXCLUDED_IDS.read_safe().clone()
    66}
    67
    68/// Snapshot of the excluded set, taken once per list so a long listing does not
    69/// re-lock per item.
    70fn excluded_snapshot() -> HashSet<String> {
    71    EXCLUDED_IDS.read_safe().iter().cloned().collect()
    72}
    73
    74/// Whether `item` falls under one of `excluded`.
    75///
    76/// The set is passed in rather than read from the global so the rule itself is a
    77/// pure function and can be tested without touching process state.
    78///
    79/// An item matches on its own id or on any of the *links* it carries back to a
    80/// container: parent, album, library, series or season, and its artist entries.
    81/// That covers the shapes a hidden folder actually reaches a listing in — the
    82/// folder itself in a container listing, its albums (whose `parent_id` is the
    83/// folder), and their tracks (whose `album_id` is the album). It is deliberately
    84/// link-based rather than a full ancestry walk: the repository has no ancestor
    85/// index, and walking one would cost a round trip per row.
    86///
    87/// TRACES: UR-076 | DR-209
    88pub fn is_excluded_by(excluded: &HashSet<String>, item: &MediaItem) -> bool {
    89    if excluded.is_empty() {
    90        return false;
    91    }
    92
    93    fn hidden(excluded: &HashSet<String>, id: &str) -> bool {
    94        excluded.contains(&normalise_id(id))
    95    }
    96
    97    fn hidden_opt(excluded: &HashSet<String>, id: &Option<String>) -> bool {
    98        match id {
    99            Some(id) => hidden(excluded, id),
    100            None => false,
    101        }
    102    }
    103
    104    hidden(excluded, &item.id)
    105        || hidden_opt(excluded, &item.parent_id)
    106        || hidden_opt(excluded, &item.album_id)
    107        || hidden_opt(excluded, &item.library_id)
    108        || hidden_opt(excluded, &item.series_id)
    109        || hidden_opt(excluded, &item.season_id)
    110        || match &item.artist_items {
    111            Some(artists) => artists.iter().any(|a| hidden(excluded, &a.id)),
    112            None => false,
    113        }
    114}
    115
    116/// Drop the user's hidden items from a repository result.
    117///
    118/// Implemented as a trait so the hybrid repository's generic result helpers —
    119/// where the cache and server legs of every cache-first race converge — can
    120/// apply it to whatever they are carrying, instead of each query having to
    121/// remember to.
    122///
    123/// TRACES: UR-076 | DR-209
    124pub trait ExcludeHidden: Sized {
    125    fn without_excluded(self) -> Self;
    126}
    127
    128impl ExcludeHidden for Vec<MediaItem> {
    129    fn without_excluded(mut self) -> Self {
    130        let excluded = excluded_snapshot();
    131        if excluded.is_empty() {
    132            return self;
    133        }
    134        self.retain(|item| !is_excluded_by(&excluded, item));
    135        self
    136    }
    137}
    138
    139impl ExcludeHidden for SearchResult {
    140    fn without_excluded(mut self) -> Self {
    141        let before = self.items.len();
    142        self.items = self.items.without_excluded();
    143        // `total_record_count` is what the UI shows as "N results" and what
    144        // paging is built against; leaving the server's count would advertise
    145        // rows that were just removed.
    146        let removed = before.saturating_sub(self.items.len());
    147        self.total_record_count = self.total_record_count.saturating_sub(removed);
    148        self
    149    }
    150}
    151
    152impl ExcludeHidden for MediaItem {
    153    /// A single item fetched by id is never hidden.
    154    ///
    155    /// Exclusion hides things from *browsing*. An item asked for by id was
    156    /// navigated to deliberately, or is being resolved by the player or a
    157    /// download — answering "not found" there would break playback of anything
    158    /// inside a hidden folder rather than merely tidying a listing.
    159    fn without_excluded(self) -> Self {
    160        self
    161    }
    162}
    163
    164#[cfg(test)]
    165mod tests {
    166    use super::*;
    167    use crate::repository::types::ArtistItem;
    168    use crate::utils::lock::MutexSafe;
    169    use std::sync::Mutex;
    170
    171    /// Serialises the tests that write the process-global excluded set. Cargo
    172    /// runs a crate's tests in one process, so without this two of them racing
    173    /// would see each other's ids.
    174    static EXCLUSION_TEST_LOCK: Mutex<()> = Mutex::new(());
    175
    176    fn item(id: &str) -> MediaItem {
    177        MediaItem {
    178            id: id.to_string(),
    179            name: format!("item {id}"),
    180            ..MediaItem::default()
    181        }
    182    }
    183
    184    fn excluded(ids: &[&str]) -> HashSet<String> {
    185        ids.iter().map(|id| normalise_id(id)).collect()
    186    }
    187
    188    /// The default is empty: nobody inherits another user's folder layout, which
    189    /// is exactly what the hardcoded "Podcasts" name filter did.
    190    ///
    191    /// TRACES: UR-076 | DR-209 | UT-203
    192    #[test]
    193    fn test_no_exclusions_by_default_keeps_everything() {
    194        let empty = HashSet::new();
    195        assert!(!is_excluded_by(&empty, &item("anything")));
    196
    197        let items = vec![item("a"), item("b")];
    198        assert_eq!(items.without_excluded().len(), 2);
    199    }
    200
    201    /// The folder itself, and anything linking back to it, is hidden.
    202    ///
    203    /// TRACES: UR-076 | DR-209 | UT-203
    204    #[test]
    205    fn test_excludes_the_folder_and_what_points_at_it() {
    206        let set = excluded(&["folder-1"]);
    207
    208        assert!(is_excluded_by(&set, &item("folder-1")), "the folder itself");
    209
    210        let album = MediaItem {
    211            parent_id: Some("folder-1".to_string()),
    212            ..item("album-1")
    213        };
    214        assert!(is_excluded_by(&set, &album), "an album inside the folder");
    215
    216        let track = MediaItem {
    217            album_id: Some("folder-1".to_string()),
    218            ..item("track-1")
    219        };
    220        assert!(is_excluded_by(&set, &track), "a track of the folder");
    221
    222        let elsewhere = MediaItem {
    223            parent_id: Some("folder-2".to_string()),
    224            ..item("album-2")
    225        };
    226        assert!(!is_excluded_by(&set, &elsewhere), "an unrelated album");
    227    }
    228
    229    /// A whole library, a series/season and an artist are all excludable by the
    230    /// same check — the setting is "hide this container", not "hide albums".
    231    ///
    232    /// TRACES: UR-076 | DR-209 | UT-203
    233    #[test]
    234    fn test_excludes_via_every_container_link() {
    235        let set = excluded(&["container"]);
    236
    237        let by_library = MediaItem {
    238            library_id: Some("container".to_string()),
    239            ..item("x")
    240        };
    241        assert!(is_excluded_by(&set, &by_library));
    242
    243        let by_series = MediaItem {
    244            series_id: Some("container".to_string()),
    245            ..item("x")
    246        };
    247        assert!(is_excluded_by(&set, &by_series));
    248
    249        let by_season = MediaItem {
    250            season_id: Some("container".to_string()),
    251            ..item("x")
    252        };
    253        assert!(is_excluded_by(&set, &by_season));
    254
    255        let by_artist = MediaItem {
    256            artist_items: Some(vec![
    257                ArtistItem {
    258                    id: "other".to_string(),
    259                    name: "Other".to_string(),
    260                },
    261                ArtistItem {
    262                    id: "container".to_string(),
    263                    name: "Hidden".to_string(),
    264                },
    265            ]),
    266            ..item("x")
    267        };
    268        assert!(is_excluded_by(&set, &by_artist));
    269    }
    270
    271    /// Ids are matched by identity, not spelling: Jellyfin serves the same GUID
    272    /// dashed on one endpoint and undashed on another, and a stored id that
    273    /// stopped matching would silently un-hide the folder.
    274    ///
    275    /// TRACES: UR-076 | DR-209 | UT-203
    276    #[test]
    277    fn test_id_matching_ignores_dashes_case_and_padding() {
    278        let set = excluded(&[" A1B2C3D4-0000-0000-0000-000000000000 "]);
    279        assert!(is_excluded_by(
    280            &set,
    281            &item("a1b2c3d4-0000-0000-0000-000000000000")
    282        ));
    283        assert!(is_excluded_by(
    284            &set,
    285            &item("A1B2C3D4000000000000000000000000")
    286        ));
    287        assert!(!is_excluded_by(&set, &item("a1b2c3d4-0000-0000-0000-1")));
    288    }
    289
    290    /// Filtering a `SearchResult` must also correct its count — the listing
    291    /// header reads it, and a stale total advertises rows that are not there.
    292    ///
    293    /// TRACES: UR-076 | DR-209 | UT-203
    294    #[test]
    295    fn test_search_result_count_follows_the_filter() {
    296        let _guard = EXCLUSION_TEST_LOCK.lock_safe();
    297        set_excluded_item_ids(&["hidden".to_string()]);
    298
    299        let result = SearchResult {
    300            items: vec![item("keep"), item("hidden"), item("keep-2")],
    301            total_record_count: 3,
    302        }
    303        .without_excluded();
    304
    305        set_excluded_item_ids(&[]);
    306
    307        assert_eq!(result.items.len(), 2);
    308        assert_eq!(result.total_record_count, 2);
    309        assert!(result.items.iter().all(|i| i.id != "hidden"));
    310    }
    311
    312    /// A single item asked for by id is never withheld: exclusion hides things
    313    /// from browsing, and refusing it here would break playback and downloads of
    314    /// anything inside a hidden folder.
    315    ///
    316    /// TRACES: UR-076 | DR-209 | UT-203
    317    #[test]
    318    fn test_direct_item_lookup_is_never_hidden() {
    319        let _guard = EXCLUSION_TEST_LOCK.lock_safe();
    320        set_excluded_item_ids(&["hidden".to_string()]);
    321
    322        let still_matches = is_excluded_by(&excluded_snapshot(), &item("hidden"));
    323        let survives = item("hidden").without_excluded();
    324
    325        set_excluded_item_ids(&[]);
    326
    327        assert!(still_matches, "the predicate still matches the item");
    328        assert_eq!(survives.id, "hidden", "but a direct lookup keeps it");
    329    }
    330
    331    /// The stored set is sanitised on the way in: blanks dropped, duplicates
    332    /// collapsed, spellings normalised.
    333    ///
    334    /// TRACES: UR-076 | DR-209 | UT-203
    335    #[test]
    336    fn test_set_excluded_item_ids_sanitises() {
    337        let _guard = EXCLUSION_TEST_LOCK.lock_safe();
    338        set_excluded_item_ids(&[
    339            "  ".to_string(),
    340            "AB-CD".to_string(),
    341            "abcd".to_string(),
    342            "ef".to_string(),
    343        ]);
    344        let stored = excluded_item_ids();
    345
    346        set_excluded_item_ids(&[]);
    347        let cleared = excluded_item_ids();
    348
    349        assert_eq!(stored, vec!["abcd".to_string(), "ef".to_string()]);
    350        assert!(cleared.is_empty());
    351    }
    352}