//! Library folders the user has chosen to keep out of browsing. //! //! Some people file things inside a library that they never want to see while //! browsing it — a "Podcasts" folder sitting in the music library is the //! canonical case: its albums and tracks leak into album, artist, track and //! playlist listings even though the user thinks of them as a different medium. //! //! This is a *domain* rule, not a presentation one: what an item belongs to, and //! therefore whether a query should return it, is decided here in the repository //! layer so every query path agrees. The predecessor of this module was a //! frontend filter that dropped anything literally named "Podcasts" — one user's //! folder layout, keyed on an English string, shipped to everyone. Excluding by //! **id** instead of name is what makes the setting survive a rename, a //! translation, or two folders sharing a name. //! //! The excluded set is process-wide rather than a field on a repository for the //! same reason as `online::STREAMING_QUALITY`: it is a preference about *this //! user's browsing*, not about a server session, so it must survive a repository //! being rebuilt on re-login. It is written by the settings command and restored //! from the database at startup. //! //! TRACES: UR-076 | DR-209 use std::collections::HashSet; use std::sync::RwLock; use super::types::{MediaItem, SearchResult}; use crate::utils::lock::RwLockSafe; /// Ids (normalised — see [`normalise_id`]) of items the user has hidden. /// /// Empty by default: nobody inherits somebody else's folder layout. /// /// TRACES: UR-076 | DR-209 static EXCLUDED_IDS: RwLock> = RwLock::new(Vec::new()); /// Jellyfin writes the same GUID both dashed and undashed depending on the /// endpoint, and ids arriving over IPC may carry stray whitespace. Comparing a /// canonical form means a stored id keeps matching whichever spelling a query /// happens to return. fn normalise_id(id: &str) -> String { id.trim().replace('-', "").to_ascii_lowercase() } /// Replace the excluded set. Ids are normalised, de-duplicated and blanks /// dropped, so a malformed value can never hide more than it names. /// /// TRACES: UR-076 | DR-209 pub fn set_excluded_item_ids(ids: &[String]) { let mut normalised: Vec = Vec::with_capacity(ids.len()); for id in ids { let id = normalise_id(id); if id.is_empty() || normalised.contains(&id) { continue; } normalised.push(id); } *EXCLUDED_IDS.write_safe() = normalised; } /// The excluded set as currently applied, normalised. /// /// TRACES: UR-076 | DR-209 pub fn excluded_item_ids() -> Vec { EXCLUDED_IDS.read_safe().clone() } /// Snapshot of the excluded set, taken once per list so a long listing does not /// re-lock per item. fn excluded_snapshot() -> HashSet { EXCLUDED_IDS.read_safe().iter().cloned().collect() } /// Whether `item` falls under one of `excluded`. /// /// The set is passed in rather than read from the global so the rule itself is a /// pure function and can be tested without touching process state. /// /// An item matches on its own id or on any of the *links* it carries back to a /// container: parent, album, library, series or season, and its artist entries. /// That covers the shapes a hidden folder actually reaches a listing in — the /// folder itself in a container listing, its albums (whose `parent_id` is the /// folder), and their tracks (whose `album_id` is the album). It is deliberately /// link-based rather than a full ancestry walk: the repository has no ancestor /// index, and walking one would cost a round trip per row. /// /// TRACES: UR-076 | DR-209 pub fn is_excluded_by(excluded: &HashSet, item: &MediaItem) -> bool { if excluded.is_empty() { return false; } fn hidden(excluded: &HashSet, id: &str) -> bool { excluded.contains(&normalise_id(id)) } fn hidden_opt(excluded: &HashSet, id: &Option) -> bool { match id { Some(id) => hidden(excluded, id), None => false, } } hidden(excluded, &item.id) || hidden_opt(excluded, &item.parent_id) || hidden_opt(excluded, &item.album_id) || hidden_opt(excluded, &item.library_id) || hidden_opt(excluded, &item.series_id) || hidden_opt(excluded, &item.season_id) || match &item.artist_items { Some(artists) => artists.iter().any(|a| hidden(excluded, &a.id)), None => false, } } /// Drop the user's hidden items from a repository result. /// /// Implemented as a trait so the hybrid repository's generic result helpers — /// where the cache and server legs of every cache-first race converge — can /// apply it to whatever they are carrying, instead of each query having to /// remember to. /// /// TRACES: UR-076 | DR-209 pub trait ExcludeHidden: Sized { fn without_excluded(self) -> Self; } impl ExcludeHidden for Vec { fn without_excluded(mut self) -> Self { let excluded = excluded_snapshot(); if excluded.is_empty() { return self; } self.retain(|item| !is_excluded_by(&excluded, item)); self } } impl ExcludeHidden for SearchResult { fn without_excluded(mut self) -> Self { let before = self.items.len(); self.items = self.items.without_excluded(); // `total_record_count` is what the UI shows as "N results" and what // paging is built against; leaving the server's count would advertise // rows that were just removed. let removed = before.saturating_sub(self.items.len()); self.total_record_count = self.total_record_count.saturating_sub(removed); self } } impl ExcludeHidden for MediaItem { /// A single item fetched by id is never hidden. /// /// Exclusion hides things from *browsing*. An item asked for by id was /// navigated to deliberately, or is being resolved by the player or a /// download — answering "not found" there would break playback of anything /// inside a hidden folder rather than merely tidying a listing. fn without_excluded(self) -> Self { self } } #[cfg(test)] mod tests { use super::*; use crate::repository::types::ArtistItem; use crate::utils::lock::MutexSafe; use std::sync::Mutex; /// Serialises the tests that write the process-global excluded set. Cargo /// runs a crate's tests in one process, so without this two of them racing /// would see each other's ids. static EXCLUSION_TEST_LOCK: Mutex<()> = Mutex::new(()); fn item(id: &str) -> MediaItem { MediaItem { id: id.to_string(), name: format!("item {id}"), ..MediaItem::default() } } fn excluded(ids: &[&str]) -> HashSet { ids.iter().map(|id| normalise_id(id)).collect() } /// The default is empty: nobody inherits another user's folder layout, which /// is exactly what the hardcoded "Podcasts" name filter did. /// /// TRACES: UR-076 | DR-209 | UT-203 #[test] fn test_no_exclusions_by_default_keeps_everything() { let empty = HashSet::new(); assert!(!is_excluded_by(&empty, &item("anything"))); let items = vec![item("a"), item("b")]; assert_eq!(items.without_excluded().len(), 2); } /// The folder itself, and anything linking back to it, is hidden. /// /// TRACES: UR-076 | DR-209 | UT-203 #[test] fn test_excludes_the_folder_and_what_points_at_it() { let set = excluded(&["folder-1"]); assert!(is_excluded_by(&set, &item("folder-1")), "the folder itself"); let album = MediaItem { parent_id: Some("folder-1".to_string()), ..item("album-1") }; assert!(is_excluded_by(&set, &album), "an album inside the folder"); let track = MediaItem { album_id: Some("folder-1".to_string()), ..item("track-1") }; assert!(is_excluded_by(&set, &track), "a track of the folder"); let elsewhere = MediaItem { parent_id: Some("folder-2".to_string()), ..item("album-2") }; assert!(!is_excluded_by(&set, &elsewhere), "an unrelated album"); } /// A whole library, a series/season and an artist are all excludable by the /// same check — the setting is "hide this container", not "hide albums". /// /// TRACES: UR-076 | DR-209 | UT-203 #[test] fn test_excludes_via_every_container_link() { let set = excluded(&["container"]); let by_library = MediaItem { library_id: Some("container".to_string()), ..item("x") }; assert!(is_excluded_by(&set, &by_library)); let by_series = MediaItem { series_id: Some("container".to_string()), ..item("x") }; assert!(is_excluded_by(&set, &by_series)); let by_season = MediaItem { season_id: Some("container".to_string()), ..item("x") }; assert!(is_excluded_by(&set, &by_season)); let by_artist = MediaItem { artist_items: Some(vec![ ArtistItem { id: "other".to_string(), name: "Other".to_string(), }, ArtistItem { id: "container".to_string(), name: "Hidden".to_string(), }, ]), ..item("x") }; assert!(is_excluded_by(&set, &by_artist)); } /// Ids are matched by identity, not spelling: Jellyfin serves the same GUID /// dashed on one endpoint and undashed on another, and a stored id that /// stopped matching would silently un-hide the folder. /// /// TRACES: UR-076 | DR-209 | UT-203 #[test] fn test_id_matching_ignores_dashes_case_and_padding() { let set = excluded(&[" A1B2C3D4-0000-0000-0000-000000000000 "]); assert!(is_excluded_by( &set, &item("a1b2c3d4-0000-0000-0000-000000000000") )); assert!(is_excluded_by( &set, &item("A1B2C3D4000000000000000000000000") )); assert!(!is_excluded_by(&set, &item("a1b2c3d4-0000-0000-0000-1"))); } /// Filtering a `SearchResult` must also correct its count — the listing /// header reads it, and a stale total advertises rows that are not there. /// /// TRACES: UR-076 | DR-209 | UT-203 #[test] fn test_search_result_count_follows_the_filter() { let _guard = EXCLUSION_TEST_LOCK.lock_safe(); set_excluded_item_ids(&["hidden".to_string()]); let result = SearchResult { items: vec![item("keep"), item("hidden"), item("keep-2")], total_record_count: 3, } .without_excluded(); set_excluded_item_ids(&[]); assert_eq!(result.items.len(), 2); assert_eq!(result.total_record_count, 2); assert!(result.items.iter().all(|i| i.id != "hidden")); } /// A single item asked for by id is never withheld: exclusion hides things /// from browsing, and refusing it here would break playback and downloads of /// anything inside a hidden folder. /// /// TRACES: UR-076 | DR-209 | UT-203 #[test] fn test_direct_item_lookup_is_never_hidden() { let _guard = EXCLUSION_TEST_LOCK.lock_safe(); set_excluded_item_ids(&["hidden".to_string()]); let still_matches = is_excluded_by(&excluded_snapshot(), &item("hidden")); let survives = item("hidden").without_excluded(); set_excluded_item_ids(&[]); assert!(still_matches, "the predicate still matches the item"); assert_eq!(survives.id, "hidden", "but a direct lookup keeps it"); } /// The stored set is sanitised on the way in: blanks dropped, duplicates /// collapsed, spellings normalised. /// /// TRACES: UR-076 | DR-209 | UT-203 #[test] fn test_set_excluded_item_ids_sanitises() { let _guard = EXCLUSION_TEST_LOCK.lock_safe(); set_excluded_item_ids(&[ " ".to_string(), "AB-CD".to_string(), "abcd".to_string(), "ef".to_string(), ]); let stored = excluded_item_ids(); set_excluded_item_ids(&[]); let cleared = excluded_item_ids(); assert_eq!(stored, vec!["abcd".to_string(), "ef".to_string()]); assert!(cleared.is_empty()); } }