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));
}
+124 -9
View File
@@ -1,4 +1,4 @@
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057 | DR-030, DR-048, DR-077, DR-086, DR-132 -->
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057, UR-076 | DR-030, DR-048, DR-077, DR-086, DR-132, DR-209 -->
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { commands } from "$lib/api/bindings";
@@ -6,6 +6,8 @@
AudioSettings,
CacheConfig,
EqPreset,
ExclusionCandidate,
LibrarySettings,
StreamingQuality,
VideoSettings,
VolumeLevel,
@@ -23,6 +25,7 @@
import SearchGroupOrderList from "$lib/components/settings/SearchGroupOrderList.svelte";
import PendingSyncList from "$lib/components/sync/PendingSyncList.svelte";
import { library, viewMode } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import {
isNetworkDetectionSupported,
reportNetworkState,
@@ -85,6 +88,16 @@
temporaryTtlHours: 24 * 7,
});
// Folders the user has hidden from browsing, and the folders they may choose
// from. Both come from Rust: which containers are offerable, and what hiding
// one actually excludes, are domain decisions — this page only renders the
// list and sends back the ids that are ticked.
// TRACES: UR-076 | DR-209
let librarySettings = $state<LibrarySettings>({ excludedItemIds: [] });
let exclusionCandidates = $state<ExclusionCandidate[]>([]);
let exclusionsLoading = $state(false);
const excludedIds = $derived(new Set(librarySettings.excludedItemIds ?? []));
// Whether the platform can actually detect the network type. On desktop it
// can't, so the WiFi-only toggle would be inert — we disable and explain it
// rather than offering a switch that does nothing.
@@ -133,13 +146,16 @@
try {
loading = true;
networkDetectionSupported = isNetworkDetectionSupported();
const [audioResult, videoResult, cacheResult, presets, qualities] = await Promise.all([
commands.playerGetAudioSettings(),
commands.playerGetVideoSettings(),
getCacheConfig(),
commands.playerGetEqPresets(),
commands.playerGetStreamingQualities(),
]);
const [audioResult, videoResult, cacheResult, presets, qualities, libraryResult] =
await Promise.all([
commands.playerGetAudioSettings(),
commands.playerGetVideoSettings(),
getCacheConfig(),
commands.playerGetEqPresets(),
commands.playerGetStreamingQualities(),
commands.libraryGetSettings(),
]);
librarySettings = libraryResult;
// equalizerBands is optional on the wire (serde default); guarantee a
// dense 10-band array so the slider bindings are never undefined.
settings = {
@@ -150,8 +166,10 @@
cacheConfig = cacheResult;
eqPresets = presets;
streamingQualities = qualities;
// Load cache stats in parallel but don't block on it
// Load cache stats and the folder picker in parallel but don't block on
// either — both need a round trip the rest of the page doesn't.
loadCacheStats();
loadExclusionCandidates();
} catch (e) {
console.error("Failed to load settings:", e);
} finally {
@@ -159,6 +177,47 @@
}
}
/**
* Ask the backend which folders may be hidden. Needs a live repository, so it
* quietly renders nothing when signed out rather than erroring on a page that
* is otherwise perfectly usable offline.
*
* TRACES: UR-076 | DR-209
*/
async function loadExclusionCandidates() {
try {
exclusionsLoading = true;
const handle = auth.getRepository().getHandle();
exclusionCandidates = await commands.libraryGetExclusionCandidates(handle);
} catch (e) {
console.warn("Failed to load library folders:", e);
exclusionCandidates = [];
} finally {
exclusionsLoading = false;
}
}
/**
* Tick or untick one folder. The backend returns the list it actually stored,
* so the picker shows what is in force rather than what was requested.
*
* TRACES: UR-076 | DR-209
*/
async function toggleExcludedItem(itemId: string) {
const current = librarySettings.excludedItemIds ?? [];
const next = current.includes(itemId)
? current.filter((id) => id !== itemId)
: [...current, itemId];
// Optimistic, so the checkbox doesn't lag a round trip behind the tap.
librarySettings = { ...librarySettings, excludedItemIds: next };
try {
librarySettings = await commands.librarySetSettings({ excludedItemIds: next });
} catch (e) {
console.error("Failed to save hidden folders:", e);
librarySettings = { ...librarySettings, excludedItemIds: current };
}
}
async function loadCacheStats() {
try {
cacheLoading = true;
@@ -415,6 +474,62 @@
</div>
</div>
<!-- Hidden folders — music libraries often hold a folder of something the
user doesn't think of as music (podcasts, audiobooks, sound effects),
which otherwise turns up in every album, artist and track listing.
The candidate list and the meaning of "hidden" both come from Rust.
TRACES: UR-076 | DR-209 -->
<div id="hidden-folders" class="scroll-mt-4 bg-[var(--color-surface)] rounded-lg p-6">
<div class="mb-4">
<h2 class="text-xl font-semibold text-white">Hidden Folders</h2>
<p class="text-sm text-gray-400 mt-1">
Folders to leave out of music browsing and search. Useful when a
music library also holds podcasts or audiobooks. Hidden folders can
still be opened from a direct link, and anything already playing or
downloaded is unaffected.
</p>
</div>
{#if exclusionsLoading}
<p class="text-sm text-gray-400">Loading folders...</p>
{:else if exclusionCandidates.length === 0}
<p class="text-sm text-gray-400">
No music folders to choose from. Connect to your server to pick
folders to hide.
</p>
{:else}
<div class="space-y-2">
{#each exclusionCandidates as candidate (candidate.id)}
<button
onclick={() => toggleExcludedItem(candidate.id)}
class="w-full flex items-center justify-between gap-3 py-3 px-4 rounded-lg text-left transition-all {excludedIds.has(
candidate.id
)
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
aria-pressed={excludedIds.has(candidate.id)}
>
<span class="min-w-0">
<span class="block font-semibold truncate">{candidate.name}</span>
<span class="block text-xs opacity-75 truncate">
{candidate.isLibrary
? "Whole library"
: `In ${candidate.libraryName}`}
</span>
</span>
<span class="text-xs font-semibold uppercase tracking-wide shrink-0">
{excludedIds.has(candidate.id) ? "Hidden" : "Visible"}
</span>
</button>
{/each}
</div>
<p class="text-xs text-gray-500 mt-3">
Changes apply to listings loaded from now on; reopen a page to see
them take effect.
</p>
{/if}
</div>
<!-- Crossfade -->
<div class="bg-[var(--color-surface)] rounded-lg p-6">
<div class="flex items-start justify-between mb-4">