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}