bindings.ts picks up the library-exclusion commands and types from tauri-specta. The matrix regenerates because validation.ts and its test are gone — the doc link checker caught the stale references, which is the first time that gate has paid for itself on a generated artifact rather than a hand-written link. Also drops exclusions::is_excluded: a wrapper over is_excluded_by that only a test called, while the trait impls hoist the snapshot themselves. The test now calls the same path production does.
353 lines
12 KiB
Rust
353 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,
|
|
}
|
|
}
|
|
|
|
/// 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_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());
|
|
}
|
|
}
|