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:
2026-08-20 20:09:57 +02:00
12 changed files with 969 additions and 192 deletions
-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));
}
-118
View File
@@ -1,118 +0,0 @@
/**
* Input validation utility tests
*
* TRACES: UR-009, UR-025 | DR-015
*/
import { describe, it, expect } from "vitest";
import {
validateItemId,
validateImageType,
validateMediaSourceId,
validateNumericParam,
validateQueryParamValue,
} from "./validation";
describe("validateItemId", () => {
it("should accept valid item IDs", () => {
expect(() => validateItemId("123abc")).not.toThrow();
expect(() => validateItemId("abc-123_def")).not.toThrow();
expect(() => validateItemId("12345")).not.toThrow();
});
it("should reject empty or non-string IDs", () => {
expect(() => validateItemId("")).toThrow("must be a non-empty string");
expect(() => validateItemId(null as any)).toThrow("must be a non-empty string");
expect(() => validateItemId(undefined as any)).toThrow("must be a non-empty string");
});
it("should reject IDs exceeding max length", () => {
expect(() => validateItemId("a".repeat(51))).toThrow("exceeds maximum length");
});
it("should reject IDs with invalid characters", () => {
expect(() => validateItemId("abc/def")).toThrow("contains invalid characters");
expect(() => validateItemId("abc..def")).toThrow("contains invalid characters");
expect(() => validateItemId("abc def")).toThrow("contains invalid characters");
});
});
describe("validateImageType", () => {
it("should accept valid image types", () => {
expect(() => validateImageType("Primary")).not.toThrow();
expect(() => validateImageType("Backdrop")).not.toThrow();
expect(() => validateImageType("Banner")).not.toThrow();
expect(() => validateImageType("Logo")).not.toThrow();
});
it("should reject invalid image types", () => {
expect(() => validateImageType("InvalidType")).toThrow("not a valid image type");
expect(() => validateImageType("..")).toThrow("not a valid image type");
expect(() => validateImageType("Primary/Avatar")).toThrow("not a valid image type");
});
it("should reject empty or non-string types", () => {
expect(() => validateImageType("")).toThrow("must be a non-empty string");
});
});
describe("validateMediaSourceId", () => {
it("should accept valid media source IDs", () => {
expect(() => validateMediaSourceId("source-123")).not.toThrow();
expect(() => validateMediaSourceId("video_stream_1")).not.toThrow();
});
it("should reject IDs with invalid characters", () => {
expect(() => validateMediaSourceId("source/path")).toThrow("contains invalid characters");
expect(() => validateMediaSourceId("source..path")).toThrow("contains invalid characters");
});
it("should reject IDs exceeding max length", () => {
expect(() => validateMediaSourceId("a".repeat(51))).toThrow("exceeds maximum length");
});
});
describe("validateNumericParam", () => {
it("should accept valid numbers", () => {
expect(validateNumericParam(100)).toBe(100);
expect(validateNumericParam(0)).toBe(0);
expect(validateNumericParam(9999)).toBe(9999);
});
it("should reject non-integers", () => {
expect(() => validateNumericParam(10.5)).toThrow("must be an integer");
expect(() => validateNumericParam("100")).toThrow("must be an integer");
});
it("should respect min and max bounds", () => {
expect(() => validateNumericParam(-1, 0, 100)).toThrow("must be between 0 and 100");
expect(() => validateNumericParam(101, 0, 100)).toThrow("must be between 0 and 100");
});
it("should allow custom bounds", () => {
expect(validateNumericParam(50, 10, 100)).toBe(50);
expect(() => validateNumericParam(5, 10, 100)).toThrow("must be between 10 and 100");
});
});
describe("validateQueryParamValue", () => {
it("should accept valid query param values", () => {
expect(() => validateQueryParamValue("abc123")).not.toThrow();
expect(() => validateQueryParamValue("value-with-dash")).not.toThrow();
expect(() => validateQueryParamValue("value_with_underscore")).not.toThrow();
});
it("should reject values with invalid characters", () => {
expect(() => validateQueryParamValue("value with spaces")).toThrow("contains invalid characters");
expect(() => validateQueryParamValue("value/path")).toThrow("contains invalid characters");
expect(() => validateQueryParamValue("value?query")).toThrow("contains invalid characters");
});
it("should reject values exceeding max length", () => {
expect(() => validateQueryParamValue("a".repeat(101))).toThrow("exceeds maximum length");
});
it("should respect custom max length", () => {
expect(() => validateQueryParamValue("a".repeat(50), 40)).toThrow("exceeds maximum length");
});
});