diff --git a/src/lib/components/search/SearchResults.svelte b/src/lib/components/search/SearchResults.svelte
index 93c27170..c88266e4 100644
--- a/src/lib/components/search/SearchResults.svelte
+++ b/src/lib/components/search/SearchResults.svelte
@@ -1,36 +1,28 @@
{#if loading}
@@ -39,7 +31,7 @@
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
>
-{:else if !hasAnyResults}
+{:else if groups.length === 0}
{:else}
-
- {#if hasMusic}
-
-
Music
-
-
- {#if categorized.music.tracks.length > 0}
-
-
- Tracks ({categorized.music.tracks.length})
-
-
-
- {/if}
-
-
- {#if categorized.music.albums.length > 0}
-
-
- Albums ({categorized.music.albums.length})
-
-
- {#each categorized.music.albums as item (item.id)}
- onItemClick?.(item)}
- />
- {/each}
-
-
- {/if}
-
-
- {#if categorized.music.artists.length > 0}
-
-
- Artists ({categorized.music.artists.length})
-
-
- {#each categorized.music.artists as item (item.id)}
- onItemClick?.(item)}
- />
- {/each}
-
-
- {/if}
-
- {/if}
-
-
- {#if categorized.movies.length > 0}
+ {#each groups as group (group.id)}
- Movies ({categorized.movies.length})
+ {group.label} ({group.items.length})
-
- {#each categorized.movies as item (item.id)}
- onItemClick?.(item)}
- />
- {/each}
-
+ {#if group.id === "songs"}
+
+ {:else}
+
+ {#each group.items as item (item.id)}
+ onItemClick?.(item)}
+ />
+ {/each}
+
+ {/if}
- {/if}
-
-
- {#if categorized.tvShows.length > 0}
-
-
- TV Shows ({categorized.tvShows.length})
-
-
- {#each categorized.tvShows as item (item.id)}
- onItemClick?.(item)}
- />
- {/each}
-
-
- {/if}
+ {/each}
{/if}
diff --git a/src/lib/components/search/SearchScopeChips.svelte b/src/lib/components/search/SearchScopeChips.svelte
new file mode 100644
index 00000000..077345f3
--- /dev/null
+++ b/src/lib/components/search/SearchScopeChips.svelte
@@ -0,0 +1,65 @@
+
+
+
+ {#each SEARCH_SCOPES as s, i (s)}
+
+ {/each}
+
+
+
diff --git a/src/lib/components/settings/SearchGroupOrderList.svelte b/src/lib/components/settings/SearchGroupOrderList.svelte
new file mode 100644
index 00000000..0002bfdf
--- /dev/null
+++ b/src/lib/components/settings/SearchGroupOrderList.svelte
@@ -0,0 +1,138 @@
+
+
+
+ {#each $searchGroupOrder as id, i (id)}
+ - onDragStart(e, i)}
+ ondragover={(e) => onDragOver(e, i)}
+ ondrop={(e) => onDrop(e, i)}
+ ondragend={onDragEnd}
+ class="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-800 border transition-colors {overIndex ===
+ i && dragIndex !== i
+ ? 'border-[var(--color-jellyfin)]'
+ : 'border-gray-700'} {dragIndex === i ? 'opacity-50' : ''}"
+ >
+
+
+
+ {i + 1}
+ {GROUP_LABELS[id]}
+
+
+
+
+ {/each}
+
+
+{announcement}
+
+
+
+
+
+
diff --git a/src/lib/stores/library.ts b/src/lib/stores/library.ts
index 391bb40c..3edd3f41 100644
--- a/src/lib/stores/library.ts
+++ b/src/lib/stores/library.ts
@@ -4,6 +4,8 @@
import { writable, derived } from "svelte/store";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
+import type { SearchOptions } from "$lib/api/bindings";
+import { scopeItemTypes, type SearchScope } from "$lib/utils/searchScope";
import { auth } from "./auth";
/**
@@ -222,7 +224,17 @@ function createLibraryStore() {
}
}
- async function search(query: string) {
+ /**
+ * Search the library, optionally narrowed to a scope.
+ *
+ * `scope` is additive and defaults to `all`, which sends no
+ * `includeItemTypes` at all — see scopeItemTypes() for why that differs from
+ * listing every type. Both the online and offline repository paths already
+ * honour the filter.
+ *
+ * TRACES: UR-049 | DR-065
+ */
+ async function search(query: string, scope: SearchScope = "all") {
// Bump the request id for every call (including clears) so any in-flight
// backend update for a previous query is ignored when it arrives.
const requestId = ++searchRequestId;
@@ -247,8 +259,13 @@ function createLibraryStore() {
// Phase 1: the command resolves with instant local-cache results. The
// merged (cache + server) union arrives later via the `search-event`
// listener above, tagged with this same requestId.
+ const itemTypes = scopeItemTypes(scope);
+ const options: SearchOptions = { limit: 10000 };
+ // Omit the key entirely for the `all` scope rather than sending null.
+ if (itemTypes) options.includeItemTypes = itemTypes;
+
const result = await Promise.race([
- repo.search(query, { limit: 10000 }, requestId),
+ repo.search(query, options, requestId),
timeoutPromise
]);
diff --git a/src/lib/stores/librarySearchScope.test.ts b/src/lib/stores/librarySearchScope.test.ts
new file mode 100644
index 00000000..4490babe
--- /dev/null
+++ b/src/lib/stores/librarySearchScope.test.ts
@@ -0,0 +1,122 @@
+/**
+ * Scoped search through the library store.
+ *
+ * TRACES: UR-049 | DR-065 | UT-*
+ */
+
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { get } from "svelte/store";
+
+const searchMock = vi.fn();
+
+vi.mock("@tauri-apps/api/event", () => ({
+ listen: vi.fn(async () => () => {}),
+}));
+
+vi.mock("./auth", () => ({
+ auth: {
+ getRepository: () => ({ search: searchMock }),
+ },
+}));
+
+import { library } from "./library";
+
+function result(items: { id: string; type: string }[] = []) {
+ return { items, totalRecordCount: items.length };
+}
+
+describe("library.search scoping", () => {
+ beforeEach(() => {
+ searchMock.mockReset();
+ searchMock.mockResolvedValue(result());
+ library.clearSearch();
+ });
+
+ it("omits includeItemTypes entirely for the default (all) scope", async () => {
+ await library.search("office");
+
+ const options = searchMock.mock.calls[0][1];
+ expect(options).not.toHaveProperty("includeItemTypes");
+ expect(options.limit).toBe(10000);
+ });
+
+ it("forwards music item types when scoped to music", async () => {
+ await library.search("office", "music");
+
+ expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual([
+ "MusicAlbum",
+ "MusicArtist",
+ "Audio",
+ "Playlist",
+ ]);
+ });
+
+ it("forwards tv item types when scoped to tv", async () => {
+ await library.search("office", "tv");
+
+ expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual(["Series", "Episode"]);
+ });
+
+ it("forwards movie item types when scoped to movies", async () => {
+ await library.search("office", "movies");
+
+ expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual(["Movie"]);
+ });
+
+ it("stores results and the query on success", async () => {
+ searchMock.mockResolvedValue(result([{ id: "1", type: "Movie" }]));
+
+ await library.search("office", "movies");
+
+ const state = get(library);
+ expect(state.searchQuery).toBe("office");
+ expect(state.searchResults.map((i) => i.id)).toEqual(["1"]);
+ });
+
+ it("clears results for an empty query without hitting the repository", async () => {
+ searchMock.mockResolvedValue(result([{ id: "1", type: "Movie" }]));
+ await library.search("office", "movies");
+ searchMock.mockClear();
+
+ await library.search(" ");
+
+ expect(searchMock).not.toHaveBeenCalled();
+ const state = get(library);
+ expect(state.searchQuery).toBe("");
+ expect(state.searchResults).toEqual([]);
+ });
+
+ it("discards a superseded response (stale requestId guard)", async () => {
+ // First search resolves *after* a newer one has already started; its
+ // results must not clobber the fresher ones.
+ let resolveFirst: (value: unknown) => void = () => {};
+ searchMock.mockImplementationOnce(
+ () => new Promise((resolve) => (resolveFirst = resolve))
+ );
+ searchMock.mockResolvedValueOnce(result([{ id: "new", type: "Movie" }]));
+
+ const first = library.search("old", "all");
+ await library.search("new", "movies");
+
+ resolveFirst(result([{ id: "old", type: "Audio" }]));
+ await first;
+
+ expect(get(library).searchResults.map((i) => i.id)).toEqual(["new"]);
+ });
+
+ it("passes an increasing requestId to the repository", async () => {
+ await library.search("a");
+ await library.search("b");
+
+ const [firstId, secondId] = searchMock.mock.calls.map((c) => c[2]);
+ expect(secondId).toBeGreaterThan(firstId);
+ });
+
+ it("surfaces a repository failure as a store error", async () => {
+ searchMock.mockRejectedValue(new Error("boom"));
+
+ await expect(library.search("office", "tv")).rejects.toThrow("boom");
+ expect(get(library).error).toBe("boom");
+ expect(get(library).loadingCount).toBe(0);
+ });
+});
diff --git a/src/lib/stores/searchGroupOrder.test.ts b/src/lib/stores/searchGroupOrder.test.ts
new file mode 100644
index 00000000..873f4c1f
--- /dev/null
+++ b/src/lib/stores/searchGroupOrder.test.ts
@@ -0,0 +1,136 @@
+/**
+ * Persisted search result group order.
+ *
+ * TRACES: UR-050 | DR-066 | UT-*
+ */
+
+import { describe, it, expect, beforeEach, beforeAll, afterAll, vi } from "vitest";
+import { get } from "svelte/store";
+import { DEFAULT_GROUP_ORDER } from "$lib/utils/searchScope";
+
+const STORAGE_KEY = "jellytau-search-group-order";
+
+// This jsdom setup doesn't expose localStorage, so stand in a minimal
+// implementation — the store only uses getItem/setItem.
+const store = new Map();
+const localStorage = {
+ getItem: (key: string) => store.get(key) ?? null,
+ setItem: (key: string, value: string) => void store.set(key, value),
+ removeItem: (key: string) => void store.delete(key),
+ clear: () => store.clear(),
+};
+
+beforeAll(() => {
+ vi.stubGlobal("localStorage", localStorage);
+});
+
+afterAll(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("searchGroupOrder", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ localStorage.clear();
+ });
+
+ it("starts at the shipped default with nothing stored", async () => {
+ const { searchGroupOrder } = await import("./searchGroupOrder");
+ expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
+ });
+
+ it("loads a stored order", async () => {
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify(["tvShows", "movies", "songs", "albums", "artists"])
+ );
+ const { searchGroupOrder } = await import("./searchGroupOrder");
+ expect(get(searchGroupOrder)).toEqual([
+ "tvShows",
+ "movies",
+ "songs",
+ "albums",
+ "artists",
+ ]);
+ });
+
+ it("appends groups a partial stored order does not mention", async () => {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(["movies"]));
+ const { searchGroupOrder } = await import("./searchGroupOrder");
+ expect(get(searchGroupOrder)).toEqual([
+ "movies",
+ "songs",
+ "albums",
+ "artists",
+ "tvShows",
+ ]);
+ });
+
+ it("falls back to the default on corrupt stored JSON", async () => {
+ localStorage.setItem(STORAGE_KEY, "{not json");
+ const { searchGroupOrder } = await import("./searchGroupOrder");
+ expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
+ });
+
+ it("persists a move so the order survives a restart", async () => {
+ const { searchGroupOrder } = await import("./searchGroupOrder");
+ searchGroupOrder.move("movies", -1);
+
+ expect(get(searchGroupOrder)).toEqual([
+ "songs",
+ "albums",
+ "movies",
+ "artists",
+ "tvShows",
+ ]);
+ expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([
+ "songs",
+ "albums",
+ "movies",
+ "artists",
+ "tvShows",
+ ]);
+
+ // Simulate a fresh app start reading the same storage.
+ vi.resetModules();
+ const reloaded = await import("./searchGroupOrder");
+ expect(get(reloaded.searchGroupOrder)).toEqual([
+ "songs",
+ "albums",
+ "movies",
+ "artists",
+ "tvShows",
+ ]);
+ });
+
+ it("persists a drag reorder", async () => {
+ const { searchGroupOrder } = await import("./searchGroupOrder");
+ searchGroupOrder.reorder(4, 0);
+ expect(get(searchGroupOrder)).toEqual([
+ "tvShows",
+ "songs",
+ "albums",
+ "artists",
+ "movies",
+ ]);
+ });
+
+ it("resets to the shipped default", async () => {
+ const { searchGroupOrder } = await import("./searchGroupOrder");
+ searchGroupOrder.move("tvShows", -1);
+ searchGroupOrder.reset();
+ expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
+ });
+
+ it("normalizes an explicitly set order", async () => {
+ const { searchGroupOrder } = await import("./searchGroupOrder");
+ searchGroupOrder.set(["movies", "podcasts"] as never);
+ expect(get(searchGroupOrder)).toEqual([
+ "movies",
+ "songs",
+ "albums",
+ "artists",
+ "tvShows",
+ ]);
+ });
+});
diff --git a/src/lib/stores/searchGroupOrder.ts b/src/lib/stores/searchGroupOrder.ts
new file mode 100644
index 00000000..d842a191
--- /dev/null
+++ b/src/lib/stores/searchGroupOrder.ts
@@ -0,0 +1,77 @@
+// Persisted order of search result groups.
+//
+// Frontend-only preference, stored in localStorage per the `jellytau-view-mode`
+// precedent in library.ts — no Rust settings command backs this.
+//
+// TRACES: UR-050 | DR-066
+
+import { writable } from "svelte/store";
+import {
+ DEFAULT_GROUP_ORDER,
+ moveGroup,
+ normalizeGroupOrder,
+ reorderGroups,
+ type SearchGroupId,
+} from "$lib/utils/searchScope";
+
+const STORAGE_KEY = "jellytau-search-group-order";
+
+function load(): SearchGroupId[] {
+ if (typeof localStorage === "undefined") return [...DEFAULT_GROUP_ORDER];
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ // A corrupt or hand-edited value must not break search rendering.
+ return normalizeGroupOrder(raw ? JSON.parse(raw) : null);
+ } catch {
+ return [...DEFAULT_GROUP_ORDER];
+ }
+}
+
+function persist(order: SearchGroupId[]) {
+ if (typeof localStorage === "undefined") return;
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
+ } catch {
+ // Quota or private-mode failure — keep the in-memory order.
+ }
+}
+
+function createSearchGroupOrderStore() {
+ const { subscribe, set, update } = writable(load());
+
+ return {
+ subscribe,
+
+ set(order: SearchGroupId[]) {
+ const normalized = normalizeGroupOrder(order);
+ persist(normalized);
+ set(normalized);
+ },
+
+ /** Move one group up (-1) or down (+1) — the keyboard-accessible path. */
+ move(id: SearchGroupId, delta: number) {
+ update((order) => {
+ const next = moveGroup(order, id, delta);
+ persist(next);
+ return next;
+ });
+ },
+
+ /** Drop handler for drag-and-drop reordering. */
+ reorder(from: number, to: number) {
+ update((order) => {
+ const next = reorderGroups(order, from, to);
+ persist(next);
+ return next;
+ });
+ },
+
+ reset() {
+ const next = [...DEFAULT_GROUP_ORDER];
+ persist(next);
+ set(next);
+ },
+ };
+}
+
+export const searchGroupOrder = createSearchGroupOrderStore();
diff --git a/src/lib/utils/searchScope.test.ts b/src/lib/utils/searchScope.test.ts
new file mode 100644
index 00000000..3740e28f
--- /dev/null
+++ b/src/lib/utils/searchScope.test.ts
@@ -0,0 +1,285 @@
+import { describe, it, expect } from "vitest";
+import {
+ composeSearchGroups,
+ DEFAULT_GROUP_ORDER,
+ groupsForScope,
+ moveGroup,
+ normalizeGroupOrder,
+ reorderGroups,
+ resolveSearchScope,
+ scopeItemTypes,
+ type SearchGroupId,
+} from "./searchScope";
+
+describe("resolveSearchScope", () => {
+ it("scopes music routes to music", () => {
+ expect(resolveSearchScope("/library/music")).toBe("music");
+ expect(resolveSearchScope("/library/music/albums")).toBe("music");
+ expect(resolveSearchScope("/library/music/artists")).toBe("music");
+ expect(resolveSearchScope("/library/music/genres")).toBe("music");
+ expect(resolveSearchScope("/library/music/playlists")).toBe("music");
+ expect(resolveSearchScope("/library/music/tracks")).toBe("music");
+ });
+
+ it("scopes movie routes to movies", () => {
+ expect(resolveSearchScope("/library/movies")).toBe("movies");
+ expect(resolveSearchScope("/library/movies/all")).toBe("movies");
+ expect(resolveSearchScope("/library/movies/genres")).toBe("movies");
+ });
+
+ it("scopes tv routes to tv", () => {
+ expect(resolveSearchScope("/library/tv")).toBe("tv");
+ expect(resolveSearchScope("/library/tv/shows")).toBe("tv");
+ });
+
+ it("treats /library/shows as tv", () => {
+ // The TV genre page lives under `shows`, not `tv`.
+ expect(resolveSearchScope("/library/shows/genres")).toBe("tv");
+ expect(resolveSearchScope("/library/shows")).toBe("tv");
+ });
+
+ it("falls back to all for home, library root, search and unknown routes", () => {
+ expect(resolveSearchScope("/")).toBe("all");
+ expect(resolveSearchScope("/library")).toBe("all");
+ expect(resolveSearchScope("/search")).toBe("all");
+ expect(resolveSearchScope("/settings")).toBe("all");
+ expect(resolveSearchScope("/downloads")).toBe("all");
+ expect(resolveSearchScope("/library/abc123")).toBe("all");
+ expect(resolveSearchScope("/nonsense/route")).toBe("all");
+ });
+
+ it("tolerates trailing slashes, query strings and hashes", () => {
+ expect(resolveSearchScope("/library/music/")).toBe("music");
+ expect(resolveSearchScope("/library/tv?foo=1")).toBe("tv");
+ expect(resolveSearchScope("/library/movies#top")).toBe("movies");
+ expect(resolveSearchScope("")).toBe("all");
+ });
+
+ it("does not match a prefix that is only a partial segment", () => {
+ expect(resolveSearchScope("/library/musicvideos")).toBe("all");
+ });
+});
+
+describe("scopeItemTypes", () => {
+ it("omits the key entirely for the all scope", () => {
+ // `all` must send no includeItemTypes — an explicit union would silently
+ // drop types nobody enumerated (Person, folders).
+ expect(scopeItemTypes("all")).toBeUndefined();
+ });
+
+ it("maps each narrow scope to its item types", () => {
+ expect(scopeItemTypes("music")).toEqual(["MusicAlbum", "MusicArtist", "Audio", "Playlist"]);
+ expect(scopeItemTypes("movies")).toEqual(["Movie"]);
+ expect(scopeItemTypes("tv")).toEqual(["Series", "Episode"]);
+ });
+
+ it("returns a fresh array callers cannot mutate into the table", () => {
+ const first = scopeItemTypes("movies")!;
+ first.push("Series");
+ expect(scopeItemTypes("movies")).toEqual(["Movie"]);
+ });
+});
+
+describe("normalizeGroupOrder", () => {
+ it("returns the default for missing or non-array input", () => {
+ expect(normalizeGroupOrder(null)).toEqual([...DEFAULT_GROUP_ORDER]);
+ expect(normalizeGroupOrder(undefined)).toEqual([...DEFAULT_GROUP_ORDER]);
+ expect(normalizeGroupOrder("nonsense")).toEqual([...DEFAULT_GROUP_ORDER]);
+ expect(normalizeGroupOrder({})).toEqual([...DEFAULT_GROUP_ORDER]);
+ });
+
+ it("drops ids that no longer exist", () => {
+ expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
+ "movies",
+ "songs",
+ "albums",
+ "artists",
+ "tvShows",
+ ]);
+ });
+
+ it("appends groups a stored order does not mention", () => {
+ // A user upgrading from a build with fewer groups must not lose the new ones.
+ expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
+ "movies",
+ "songs",
+ "albums",
+ "artists",
+ "tvShows",
+ ]);
+ });
+
+ it("de-duplicates repeated ids", () => {
+ expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
+ "songs",
+ "movies",
+ "albums",
+ "artists",
+ "tvShows",
+ ]);
+ });
+
+ it("preserves a complete valid order unchanged", () => {
+ const order: SearchGroupId[] = ["tvShows", "movies", "artists", "albums", "songs"];
+ expect(normalizeGroupOrder(order)).toEqual(order);
+ });
+});
+
+describe("groupsForScope", () => {
+ it("returns every group in saved order for the all scope", () => {
+ expect(groupsForScope("all", ["movies", "songs", "tvShows", "albums", "artists"])).toEqual([
+ "movies",
+ "songs",
+ "tvShows",
+ "albums",
+ "artists",
+ ]);
+ });
+
+ it("keeps only in-scope groups, in saved order", () => {
+ const order: SearchGroupId[] = ["artists", "movies", "albums", "tvShows", "songs"];
+ expect(groupsForScope("music", order)).toEqual(["artists", "albums", "songs"]);
+ expect(groupsForScope("movies", order)).toEqual(["movies"]);
+ expect(groupsForScope("tv", order)).toEqual(["tvShows"]);
+ });
+});
+
+describe("composeSearchGroups", () => {
+ const results = [
+ { id: "1", type: "Audio" },
+ { id: "2", type: "MusicAlbum" },
+ { id: "3", type: "Movie" },
+ { id: "4", type: "Series" },
+ { id: "5", type: "Episode" },
+ { id: "6", type: "Person" },
+ ];
+
+ it("renders groups in the configured order", () => {
+ const groups = composeSearchGroups(results, "all", [
+ "tvShows",
+ "movies",
+ "songs",
+ "albums",
+ "artists",
+ ]);
+ expect(groups.map((g) => g.id)).toEqual(["tvShows", "movies", "songs", "albums"]);
+ });
+
+ it("omits empty groups", () => {
+ // No artists in the fixture, so the artists group never renders.
+ const groups = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
+ expect(groups.map((g) => g.id)).not.toContain("artists");
+ });
+
+ it("drops out-of-scope groups", () => {
+ expect(composeSearchGroups(results, "music", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
+ "songs",
+ "albums",
+ ]);
+ expect(composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
+ "tvShows",
+ ]);
+ });
+
+ it("groups series and episodes together under tvShows", () => {
+ const groups = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER);
+ expect(groups[0].items.map((i) => i.id)).toEqual(["4", "5"]);
+ });
+
+ it("ignores item types that belong to no group", () => {
+ const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
+ expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("6");
+ });
+
+ it("narrowing then widening restores the full arrangement", () => {
+ // Scope is a filter over the saved order, never a rewrite of it.
+ const order: SearchGroupId[] = ["tvShows", "songs", "movies", "albums", "artists"];
+ const wide = composeSearchGroups(results, "all", order).map((g) => g.id);
+ composeSearchGroups(results, "music", order);
+ expect(composeSearchGroups(results, "all", order).map((g) => g.id)).toEqual(wide);
+ expect(wide).toEqual(["tvShows", "songs", "movies", "albums"]);
+ });
+
+ it("survives a stored order containing an unknown id", () => {
+ const groups = composeSearchGroups(results, "all", [
+ "podcasts",
+ "movies",
+ ] as unknown as SearchGroupId[]);
+ expect(groups.map((g) => g.id)).toEqual(["movies", "songs", "albums", "tvShows"]);
+ });
+
+ it("handles items with a missing type", () => {
+ const groups = composeSearchGroups(
+ [{ id: "x", type: null }, { id: "y" }] as { id: string; type?: string | null }[],
+ "all",
+ DEFAULT_GROUP_ORDER
+ );
+ expect(groups).toEqual([]);
+ });
+});
+
+describe("moveGroup", () => {
+ const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
+
+ it("moves a group up", () => {
+ expect(moveGroup(order, "artists", -1)).toEqual([
+ "songs",
+ "artists",
+ "albums",
+ "movies",
+ "tvShows",
+ ]);
+ });
+
+ it("moves a group down", () => {
+ expect(moveGroup(order, "songs", 1)).toEqual([
+ "albums",
+ "songs",
+ "artists",
+ "movies",
+ "tvShows",
+ ]);
+ });
+
+ it("is a no-op at the boundaries", () => {
+ expect(moveGroup(order, "songs", -1)).toEqual(order);
+ expect(moveGroup(order, "tvShows", 1)).toEqual(order);
+ });
+
+ it("is a no-op for an unknown id", () => {
+ expect(moveGroup(order, "podcasts" as SearchGroupId, 1)).toEqual(order);
+ });
+
+ it("does not mutate the input", () => {
+ const input = [...order];
+ moveGroup(input, "songs", 1);
+ expect(input).toEqual(order);
+ });
+});
+
+describe("reorderGroups", () => {
+ const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
+
+ it("moves an item from one index to another", () => {
+ expect(reorderGroups(order, 0, 4)).toEqual([
+ "albums",
+ "artists",
+ "movies",
+ "tvShows",
+ "songs",
+ ]);
+ expect(reorderGroups(order, 4, 0)).toEqual([
+ "tvShows",
+ "songs",
+ "albums",
+ "artists",
+ "movies",
+ ]);
+ });
+
+ it("is a no-op for equal or out-of-range indices", () => {
+ expect(reorderGroups(order, 2, 2)).toEqual(order);
+ expect(reorderGroups(order, -1, 2)).toEqual(order);
+ expect(reorderGroups(order, 0, 9)).toEqual(order);
+ });
+});
diff --git a/src/lib/utils/searchScope.ts b/src/lib/utils/searchScope.ts
new file mode 100644
index 00000000..dc8e7b25
--- /dev/null
+++ b/src/lib/utils/searchScope.ts
@@ -0,0 +1,205 @@
+// Search scoping and result-group ordering.
+//
+// Two independent axes govern how search results are presented:
+// - *scope* narrows which item types are requested from the repository,
+// - *group order* decides the sequence the surviving groups render in.
+// Neither one rewrites the other: narrowing to Music and widening back to All
+// restores the user's saved arrangement untouched.
+//
+// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067
+
+export type SearchScope = "all" | "music" | "movies" | "tv";
+
+export const SEARCH_SCOPES: readonly SearchScope[] = ["all", "music", "movies", "tv"];
+
+export const SCOPE_LABELS: Record = {
+ all: "All",
+ music: "Music",
+ movies: "Movies",
+ tv: "TV",
+};
+
+/**
+ * Jellyfin item types requested for each scope.
+ *
+ * `all` is deliberately absent: sending no `includeItemTypes` is *not* the same
+ * as sending the union of the lists below — types nobody enumerated here
+ * (Person, folders, …) would be filtered out by an explicit list.
+ */
+const SCOPE_ITEM_TYPES: Record, string[]> = {
+ music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
+ movies: ["Movie"],
+ tv: ["Series", "Episode"],
+};
+
+/**
+ * Item types to send with a scoped search, or `undefined` for the `all` scope
+ * so the caller omits the key entirely.
+ *
+ * TRACES: UR-049 | DR-063
+ */
+export function scopeItemTypes(scope: SearchScope): string[] | undefined {
+ if (scope === "all") return undefined;
+ return [...SCOPE_ITEM_TYPES[scope]];
+}
+
+/**
+ * Resolve the scope a search started from a given route should default to.
+ * Pure — takes a pathname, touches no DOM, so it unit-tests directly.
+ *
+ * TRACES: UR-049 | DR-063
+ */
+export function resolveSearchScope(pathname: string): SearchScope {
+ // Tolerate query strings, hashes and trailing slashes.
+ const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
+
+ if (path === "/library/music" || path.startsWith("/library/music/")) return "music";
+ if (path === "/library/movies" || path.startsWith("/library/movies/")) return "movies";
+ if (path === "/library/tv" || path.startsWith("/library/tv/")) return "tv";
+ // `/library/shows/genres` is the TV genre route despite the differing segment.
+ if (path === "/library/shows" || path.startsWith("/library/shows/")) return "tv";
+
+ return "all";
+}
+
+// ---------------------------------------------------------------------------
+// Result groups
+// ---------------------------------------------------------------------------
+
+export type SearchGroupId = "songs" | "albums" | "artists" | "movies" | "tvShows";
+
+/** Shipped default order, per the spec. */
+export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [
+ "songs",
+ "albums",
+ "artists",
+ "movies",
+ "tvShows",
+];
+
+export const GROUP_LABELS: Record = {
+ songs: "Songs",
+ albums: "Albums",
+ artists: "Artists",
+ movies: "Movies",
+ tvShows: "TV Shows",
+};
+
+/** Which scopes each group belongs to (`all` always includes everything). */
+const GROUP_SCOPE: Record> = {
+ songs: "music",
+ albums: "music",
+ artists: "music",
+ movies: "movies",
+ tvShows: "tv",
+};
+
+/** Item types that fall into each group. */
+const GROUP_ITEM_TYPES: Record = {
+ songs: ["Audio"],
+ albums: ["MusicAlbum"],
+ artists: ["MusicArtist"],
+ movies: ["Movie"],
+ tvShows: ["Series", "Episode"],
+};
+
+export function groupItemTypes(group: SearchGroupId): string[] {
+ return [...GROUP_ITEM_TYPES[group]];
+}
+
+/**
+ * Normalise a stored order into a usable one.
+ *
+ * The stored array is a *hint*, not a contract: ids that no longer exist are
+ * dropped, and groups it never mentions (a user upgrading from a build with
+ * fewer groups) are appended in default order rather than lost.
+ *
+ * TRACES: UR-050 | DR-066
+ */
+export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
+ const known = new Set(DEFAULT_GROUP_ORDER);
+ const seen = new Set();
+ const order: SearchGroupId[] = [];
+
+ if (Array.isArray(stored)) {
+ for (const id of stored) {
+ if (typeof id !== "string" || !known.has(id)) continue;
+ const groupId = id as SearchGroupId;
+ if (seen.has(groupId)) continue;
+ seen.add(groupId);
+ order.push(groupId);
+ }
+ }
+
+ for (const id of DEFAULT_GROUP_ORDER) {
+ if (!seen.has(id)) order.push(id);
+ }
+
+ return order;
+}
+
+/** Groups visible under a scope, in the user's configured order. */
+export function groupsForScope(
+ scope: SearchScope,
+ order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
+): SearchGroupId[] {
+ return normalizeGroupOrder(order as SearchGroupId[]).filter(
+ (id) => scope === "all" || GROUP_SCOPE[id] === scope
+ );
+}
+
+export interface SearchGroup {
+ id: SearchGroupId;
+ label: string;
+ items: T[];
+}
+
+/**
+ * Compose scope, saved order and the results into the sections to render:
+ * drop out-of-scope groups, sort by the saved order, omit empty groups.
+ *
+ * TRACES: UR-050 | DR-067
+ */
+export function composeSearchGroups(
+ results: readonly T[],
+ scope: SearchScope,
+ order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
+): SearchGroup[] {
+ return groupsForScope(scope, order)
+ .map((id) => {
+ const types = GROUP_ITEM_TYPES[id];
+ return {
+ id,
+ label: GROUP_LABELS[id],
+ items: results.filter((item) => item.type != null && types.includes(item.type)),
+ };
+ })
+ .filter((group) => group.items.length > 0);
+}
+
+/** Move a group one slot up (-1) or down (+1); out-of-range moves are no-ops. */
+export function moveGroup(
+ order: readonly SearchGroupId[],
+ id: SearchGroupId,
+ delta: number
+): SearchGroupId[] {
+ const next = [...order];
+ const from = next.indexOf(id);
+ if (from === -1) return next;
+ const to = from + delta;
+ if (to < 0 || to >= next.length) return next;
+ next.splice(to, 0, ...next.splice(from, 1));
+ return next;
+}
+
+/** Move a group from one index to another (drag-and-drop drop handler). */
+export function reorderGroups(
+ order: readonly SearchGroupId[],
+ from: number,
+ to: number
+): SearchGroupId[] {
+ const next = [...order];
+ if (from < 0 || from >= next.length || to < 0 || to >= next.length || from === to) return next;
+ next.splice(to, 0, ...next.splice(from, 1));
+ return next;
+}
diff --git a/src/routes/library/+layout.svelte b/src/routes/library/+layout.svelte
index ed8507f2..82ab9142 100644
--- a/src/routes/library/+layout.svelte
+++ b/src/routes/library/+layout.svelte
@@ -2,11 +2,13 @@
import { onMount, onDestroy, setContext } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
- import { commands } from "$lib/api/bindings";
- import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
+ import { isAuthenticated, isLoading as isAuthLoading } from "$lib/stores/auth";
import { library } from "$lib/stores/library";
import { useScrollGuard } from "$lib/composables/useScrollGuard";
import Search from "$lib/components/Search.svelte";
+ import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
+ import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope";
+ import AppHeader from "$lib/components/AppHeader.svelte";
import BottomUi from "$lib/components/BottomUi.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
@@ -17,7 +19,6 @@
let { children } = $props();
let searchQuery = $state("");
- let showOverflowMenu = $state(false);
let showSleepTimerModal = $state(false);
onMount(() => {
@@ -33,19 +34,34 @@
}
});
- async function handleLogout() {
- await auth.logout();
- library.reset();
- goto("/");
- }
+ // The header search outlives navigation, so the route seeds the scope only
+ // while no search is active. Once the user has typed (or picked a chip),
+ // their scope governs until they clear the query — navigating must not snap
+ // a widened search back to the section they happen to be in.
+ // TRACES: UR-049 | DR-064
+ let searchScope = $state(resolveSearchScope($page.url.pathname));
+
+ $effect(() => {
+ const pathname = $page.url.pathname;
+ if (!searchQuery.trim()) {
+ searchScope = resolveSearchScope(pathname);
+ }
+ });
async function handleSearch(query: string) {
if (query.trim()) {
- await library.search(query);
+ await library.search(query, searchScope);
} else {
library.clearSearch();
}
}
+
+ async function handleScopeChange(next: SearchScope) {
+ searchScope = next;
+ if (searchQuery.trim()) {
+ await library.search(searchQuery, next);
+ }
+ }
{#if $isAuthLoading}
@@ -54,140 +70,19 @@
{:else if $isAuthenticated}
-
-
-
-
-
- JellyTau
-
+
+
-
-
-
-
-
-
-
-
-
-
-
{$currentUser?.name}
-
-
-
-
-
-
-
-
-
-
-
- {#if showOverflowMenu}
-
-
showOverflowMenu = false}
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') showOverflowMenu = false; }}
- role="button"
- tabindex="0"
- aria-label="Close menu"
- >
-
-
-
- {/if}
-
-
-
-
-
-
-
+ {#snippet librarySearch()}
+
+ {#if searchQuery.trim()}
+
+ {/if}
+ {/snippet}
-
+
+
@@ -60,6 +77,7 @@
0}
+ {scope}
onItemClick={handleItemClick}
/>
{:else}