feat(library): exclude chosen folders from music browsing
Replaces a hardcoded filter that dropped anything named "Podcasts" from music results — one user's library layout compiled into the shipped product, keyed on an English literal, applied only at the six call sites someone had remembered. Exclusion is now a user setting stored in Rust and applied at the repository layer's convergence points, so scope is decided once and is the same on every screen. It matches on folder id rather than name: a title is not what an item is, which is why an album legitimately called "Podcasts" used to vanish. Deliberately not filtered: get_item (an id asked for by name was navigated to on purpose, and refusing it would break playback of anything inside a hidden folder), get_downloaded_items (hiding a download would leave the user unable to delete a file whose disk usage they can still see), and the offline cache (an exclusion is a view preference and must be reversible without a re-crawl). Also removes src/lib/utils/validation.ts — six exported validators with no caller outside their own test file, which made the module read as covered input validation while guarding nothing. TRACES: UR-076 | DR-209 | UT-203
This commit is contained in:
@@ -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,
|
||||
@@ -88,6 +91,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.
|
||||
@@ -136,13 +149,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 = {
|
||||
@@ -153,8 +169,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) {
|
||||
log.error("Failed to load settings:", e);
|
||||
} finally {
|
||||
@@ -162,6 +180,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;
|
||||
@@ -418,6 +477,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