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:
@@ -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
@@ -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: [] };
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user