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
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use async_trait::async_trait;
|
||||
use log::{debug, warn};
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use super::exclusions::ExcludeHidden;
|
||||
use super::{types::*, MediaRepository, OfflineRepository, OnlineRepository};
|
||||
|
||||
/// Hybrid repository combining online and offline data sources
|
||||
@@ -151,6 +152,27 @@ impl HybridRepository {
|
||||
Ok(result.items)
|
||||
}
|
||||
|
||||
/// Immediate children of a container with the user's browsing exclusions
|
||||
/// **not** applied.
|
||||
///
|
||||
/// Exists for the exclusion picker in settings. Everything else in this
|
||||
/// repository hides what the user has hidden, which would make the setting
|
||||
/// one-way: a folder already excluded would vanish from the list of folders
|
||||
/// to exclude and could never be un-hidden. Server-first so the picker sees
|
||||
/// the real library, falling back to the cache when unreachable.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub async fn get_items_unfiltered(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
match self.online.get_items(parent_id, options.clone()).await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(e) => self.offline.get_items(parent_id, options).await.or(Err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Search only the local SQLite cache (downloaded content).
|
||||
///
|
||||
/// Fast (100ms timeout) — used to render instant results before the server
|
||||
@@ -165,6 +187,7 @@ impl HybridRepository {
|
||||
let query = query.to_string();
|
||||
self.cache_with_timeout(async move { offline.search(&query, options).await })
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
/// Favourites held locally, without touching the server. Backs the instant
|
||||
@@ -179,6 +202,7 @@ impl HybridRepository {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
/// Favourites straight from the server, persisted to the cache on the way
|
||||
@@ -199,7 +223,7 @@ impl HybridRepository {
|
||||
debug!("[HybridRepo] Failed to cache favourites: {:?}", e);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
Ok(result.without_excluded())
|
||||
}
|
||||
|
||||
/// Fetch a folder's items from the live server and persist them to the
|
||||
@@ -235,6 +259,10 @@ impl HybridRepository {
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
// Deliberately *not* filtered by the user's hidden folders: this surface
|
||||
// manages what is on the device, and hiding a download would leave the
|
||||
// user unable to delete a file they can still see the disk usage of.
|
||||
// TRACES: UR-076 | DR-209
|
||||
self.offline.get_downloaded_items(parent_id, options).await
|
||||
}
|
||||
|
||||
@@ -258,7 +286,10 @@ impl HybridRepository {
|
||||
query: &str,
|
||||
options: Option<SearchOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
self.online.search(query, options).await
|
||||
self.online
|
||||
.search(query, options)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
/// Merge cache and server search results into a single de-duplicated list.
|
||||
@@ -318,20 +349,30 @@ impl HybridRepository {
|
||||
/// 3. If cache is empty/stale → query server (fresh data)
|
||||
/// 4. If server fails → return cache even if empty (offline fallback)
|
||||
///
|
||||
/// Both legs are passed through [`ExcludeHidden`] before the "does the cache
|
||||
/// have content?" question is asked. This is the single place the cache and
|
||||
/// server results of a cache-first query converge, so applying the user's
|
||||
/// browsing exclusions here covers every query built on it at once — and
|
||||
/// filtering *before* the content check is what makes a cache page holding
|
||||
/// nothing but hidden items fall through to the server instead of being
|
||||
/// served as an empty listing.
|
||||
///
|
||||
/// @req: UR-002 - Access media when online or offline
|
||||
/// @req: DR-013 - Repository pattern for online/offline data access
|
||||
///
|
||||
/// TRACES: UR-002, UR-076 | DR-013, DR-209
|
||||
async fn parallel_race<T, F1, F2>(
|
||||
&self,
|
||||
cache_future: F1,
|
||||
server_future: F2,
|
||||
) -> Result<T, RepoError>
|
||||
where
|
||||
T: MeaningfulContent + Clone + Send + 'static,
|
||||
T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
|
||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
{
|
||||
// Try cache first (100ms timeout already applied by callers)
|
||||
let cache_result = cache_future.await;
|
||||
let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
|
||||
|
||||
if let Ok(data) = &cache_result {
|
||||
if data.has_content() {
|
||||
@@ -343,7 +384,7 @@ impl HybridRepository {
|
||||
// Cache miss — fall back to server
|
||||
debug!("[HybridRepo] Cache miss, querying server");
|
||||
match server_future.await {
|
||||
Ok(data) => Ok(data),
|
||||
Ok(data) => Ok(data.without_excluded()),
|
||||
Err(e) => {
|
||||
// Server failed, try to return cache even if empty
|
||||
cache_result.or(Err(e))
|
||||
@@ -364,7 +405,7 @@ impl HybridRepository {
|
||||
/// The callback runs only on a cache hit — on a miss the server result is
|
||||
/// already being fetched and cached by the normal path.
|
||||
///
|
||||
/// TRACES: UR-002, UR-025 | DR-155
|
||||
/// TRACES: UR-002, UR-025, UR-076 | DR-155, DR-209
|
||||
async fn race_with_refresh<T, F1, F2, R>(
|
||||
&self,
|
||||
cache_future: F1,
|
||||
@@ -372,12 +413,12 @@ impl HybridRepository {
|
||||
on_cache_hit: R,
|
||||
) -> Result<T, RepoError>
|
||||
where
|
||||
T: MeaningfulContent + Clone + Send + 'static,
|
||||
T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
|
||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
R: FnOnce(),
|
||||
{
|
||||
let cache_result = cache_future.await;
|
||||
let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
|
||||
|
||||
if let Ok(data) = &cache_result {
|
||||
if data.has_content() {
|
||||
@@ -389,7 +430,7 @@ impl HybridRepository {
|
||||
|
||||
debug!("[HybridRepo] Cache miss, querying server");
|
||||
match server_future.await {
|
||||
Ok(data) => Ok(data),
|
||||
Ok(data) => Ok(data.without_excluded()),
|
||||
Err(e) => cache_result.or(Err(e)),
|
||||
}
|
||||
}
|
||||
@@ -475,10 +516,18 @@ impl MediaRepository for HybridRepository {
|
||||
let server_handle =
|
||||
tokio::spawn(async move { online.get_items(&parent_id_clone, options).await });
|
||||
|
||||
// Check cache first (fast, 100ms timeout)
|
||||
// Check cache first (fast, 100ms timeout).
|
||||
//
|
||||
// Exclusions are applied here rather than at each return below so the
|
||||
// "has content" decisions further down are made about what the user will
|
||||
// actually see. `get_items` is the one query that does not go through
|
||||
// `parallel_race` — it interleaves the downloads-only gate and a
|
||||
// background cache write — so it applies the filter itself.
|
||||
// TRACES: UR-076 | DR-209
|
||||
let cache_result = self
|
||||
.cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
|
||||
.await;
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded);
|
||||
|
||||
// Downloads-only gate: when the "Show all server media" toggle is off
|
||||
// (offline), an empty offline result is authoritative — the user asked
|
||||
@@ -555,7 +604,12 @@ impl MediaRepository for HybridRepository {
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(server_data)
|
||||
// The cache keeps the server's full page (above) — an exclusion
|
||||
// is a view preference and can be undone, so hiding items from
|
||||
// the *cache* would make un-hiding them require a re-crawl. Only
|
||||
// what is handed back is filtered.
|
||||
// TRACES: UR-076 | DR-209
|
||||
Ok(server_data.without_excluded())
|
||||
}
|
||||
Ok(Err(e)) => cache_result.or(Err(e)),
|
||||
Err(join_err) => cache_result.or(Err(RepoError::Network {
|
||||
@@ -667,7 +721,10 @@ impl MediaRepository for HybridRepository {
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Next up is dynamic, always fetch from server
|
||||
self.online.get_next_up_episodes(series_id, limit).await
|
||||
self.online
|
||||
.get_next_up_episodes(series_id, limit)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
async fn get_recently_played_audio(
|
||||
@@ -831,12 +888,18 @@ impl MediaRepository for HybridRepository {
|
||||
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Live TV requires server communication - delegate to online repository
|
||||
self.online.get_live_tv_channels().await
|
||||
self.online
|
||||
.get_live_tv_channels()
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
|
||||
// Plugin channels require server communication - delegate to online repository
|
||||
self.online.get_channels().await
|
||||
self.online
|
||||
.get_channels()
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod device_profile;
|
||||
/// User-chosen browsing exclusions (UR-076 / DR-209).
|
||||
pub mod exclusions;
|
||||
pub mod hybrid;
|
||||
pub mod offline;
|
||||
pub mod online;
|
||||
|
||||
Reference in New Issue
Block a user