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:
2026-08-20 19:38:05 +02:00
parent 51d914777a
commit ac3cd67164
11 changed files with 969 additions and 74 deletions
+78 -15
View File
@@ -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> {