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
@@ -19,7 +19,6 @@
import LibraryGrid from "./LibraryGrid.svelte";
import TrackList from "./TrackList.svelte";
import AlphabetScrollBar from "./AlphabetScrollBar.svelte";
import { excludePodcasts } from "$lib/utils/podcastFilter";
/**
* Generic media list page supporting Albums, Artists, Playlists, and Tracks
@@ -84,7 +83,7 @@
unlistenSearch = await listen<SearchUpdateEvent>("search-event", (event) => {
const { requestId, result } = event.payload;
if (requestId !== searchRequestId) return;
items = excludePodcasts(result.items);
items = result.items;
});
}
@@ -118,8 +117,9 @@
if (items.length === 0) loading = true;
const repo = auth.getRepository();
// Use backend search if search query is provided, otherwise use getItems with sort
// HACK: excludePodcasts drops the "Podcasts" folder stored in the music library.
// Use backend search if search query is provided, otherwise use getItems
// with sort. Neither result is filtered here: folders the user chose to
// hide are dropped by the repository layer. TRACES: UR-076 | DR-209
if (debouncedSearchQuery.trim()) {
// Phase 1: instant cache-only (downloaded) results. The merged
// cache+server union arrives later via the `search-event` listener,
@@ -136,7 +136,7 @@
);
// Only apply if this is still the active query.
if (requestId === searchRequestId) {
items = excludePodcasts(result.items);
items = result.items;
}
} else {
// Leaving search — invalidate any in-flight server results.
@@ -151,7 +151,7 @@
// resolves to online vs offline. TRACES: UR-067 | DR-116
favoritesOnly: favoritesOnly ? true : undefined,
});
items = excludePodcasts(result.items);
items = result.items;
}
} catch (e) {
console.error(`Failed to load ${config.itemType}:`, e);
+9 -14
View File
@@ -4,7 +4,6 @@
import { writable, derived } from "svelte/store";
import type { MediaItem, Genre } from "$lib/api/types";
import { auth } from "./auth";
import { excludePodcasts } from "$lib/utils/podcastFilter";
import { selectDiverseGenres, sampleAcross } from "$lib/utils/genreDiversity";
import { buildHeroMix } from "$lib/utils/heroMix";
@@ -97,23 +96,20 @@ function createMusicStore() {
.catch(() => [] as MediaItem[]),
]);
// HACK: drop the "Podcasts" folder that lives inside the music library.
const recentlyPlayedAlbums = excludePodcasts(recentlyPlayed);
const newlyAddedAlbums = excludePodcasts(newlyAdded.items);
const playlistItems = excludePodcasts(playlistsResult.items);
const rediscoverAlbums = excludePodcasts(rediscover);
const surpriseAlbums = excludePodcasts(surprise);
// Nothing is filtered here: folders the user chose to hide are already
// gone, dropped by the repository layer that answered these queries.
// TRACES: UR-076 | DR-209
// Mix the hero: fresh-in-your-ears first, then "remember this?", then
// random albums from across the library.
const heroItems = buildHeroMix([recentlyPlayedAlbums, rediscoverAlbums, surpriseAlbums], hasArt);
const heroItems = buildHeroMix([recentlyPlayed, rediscover, surprise], hasArt);
update(s => ({
...s,
recentlyPlayed: recentlyPlayedAlbums,
newlyAdded: newlyAddedAlbums,
playlists: playlistItems,
rediscover: rediscoverAlbums,
recentlyPlayed,
newlyAdded: newlyAdded.items,
playlists: playlistsResult.items,
rediscover,
heroItems,
isLoading: false,
}));
@@ -140,8 +136,7 @@ function createMusicStore() {
recursive: true,
limit: SECTION_LIMIT,
});
// HACK: drop the "Podcasts" folder that lives in the music library.
return { id: genre.id, name: genre.name, items: excludePodcasts(result.items) };
return { id: genre.id, name: genre.name, items: result.items };
} catch (e) {
console.warn(`Failed to load genre row "${genre.name}":`, e);
return { id: genre.id, name: genre.name, items: [] };
-30
View File
@@ -1,30 +0,0 @@
// HACK: hide "Podcasts" from the music library.
//
// The user stores podcasts inside the music library under a folder/album named
// "Podcasts", so they leak into album/artist/track/playlist queries. Jellyfin's
// item queries here don't give us a clean server-side exclusion for that folder,
// so we filter client-side by name. This is intentionally a blunt instrument:
// anything whose own name, album, or (album) artist is literally "Podcasts" is
// dropped. If the folder is ever renamed, update PODCAST_FOLDER_NAME.
import type { MediaItem } from "$lib/api/types";
const PODCAST_FOLDER_NAME = "podcasts";
function isPodcastName(value: string | null | undefined): boolean {
return value?.trim().toLowerCase() === PODCAST_FOLDER_NAME;
}
/** True when an item belongs to the "Podcasts" folder/album and should be hidden. */
export function isPodcastItem(item: MediaItem): boolean {
return (
isPodcastName(item.name) ||
isPodcastName(item.albumName) ||
isPodcastName(item.albumArtist) ||
(item.artists?.some(isPodcastName) ?? false)
);
}
/** Remove "Podcasts" entries from a list of music items. */
export function excludePodcasts(items: MediaItem[]): MediaItem[] {
return items.filter((item) => !isPodcastItem(item));
}