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}