Files
jellytau/src-tauri/src/repository/exclusions.rs
T
dtourolle ac3cd67164 feat(library): exclude chosen folders from music browsing
Replaces `src/lib/utils/podcastFilter.ts` — a shipped personal workaround
that dropped any item whose name, album, album artist or artist was
literally "Podcasts" — with a real user setting applied in Rust.

The old filter was wrong twice over: it hardcoded one user's folder
layout keyed on an English literal, and it put a domain rule (what a
query should return) in the presentation layer. It slipped past
`check:boundary` only because it matched on names rather than on an
item-type array.

- `repository::exclusions` owns the rule and the process-wide id set,
  the same shape as `online::STREAMING_QUALITY` so it survives a
  repository being rebuilt on re-login.
- `HybridRepository` applies it where the cache and server legs of every
  cache-first query converge (`parallel_race` / `race_with_refresh`),
  plus the bespoke `get_items` path and the server-only reads. Filtering
  before the "has content" check is what makes a cache page of nothing
  but hidden items fall through to the server.
- Exclusion is by stable item id, never by name, and matches an item's
  own id or any container link it carries (parent, album, library,
  series, season, artist).
- A direct `get_item` lookup and the Downloads surface are deliberately
  unfiltered: hiding those would break playback and file management of
  anything inside a hidden folder.
- `LibrarySettings` persists to `app_settings` and is restored in the
  setup hook, alongside the streaming-quality cap. Default is an empty
  list — nobody inherits the old "Podcasts" behaviour.
- New commands `library_get_settings`, `library_set_settings` and
  `library_get_exclusion_candidates`; the candidates read goes through
  `get_items_unfiltered` so an already-hidden folder still appears in the
  picker and the setting can be undone.
- Settings page gains a "Hidden Folders" section that renders the
  backend's candidate list and sends back ticked ids; it decides nothing.

TRACES: UR-076 | DR-209 | UT-203
2026-08-20 19:38:05 +02:00

360 lines
12 KiB
Rust

//! 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<Vec<String>> = 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<String> = 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<String> {
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<String> {
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<String>, item: &MediaItem) -> bool {
if excluded.is_empty() {
return false;
}
fn hidden(excluded: &HashSet<String>, id: &str) -> bool {
excluded.contains(&normalise_id(id))
}
fn hidden_opt(excluded: &HashSet<String>, id: &Option<String>) -> 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,
}
}
/// Whether the currently-configured exclusions hide `item`.
///
/// TRACES: UR-076 | DR-209
pub fn is_excluded(item: &MediaItem) -> bool {
is_excluded_by(&excluded_snapshot(), item)
}
/// 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<MediaItem> {
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<String> {
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(&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());
}
}