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}
@@ -48,98 +40,27 @@
{: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 @@ + + + + +
{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 - + + - - - - - - - -
- - - - - - -
- - - - {#if showOverflowMenu} - -
showOverflowMenu = false} - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') showOverflowMenu = false; }} - role="button" - tabindex="0" - aria-label="Close menu" - >
- - -
- showOverflowMenu = false} - > - - - - Downloads - - showOverflowMenu = false} - > - - - - - Settings - -
- -
- {/if} -
- - - -
-
-
+ {#snippet librarySearch()} + + {#if searchQuery.trim()} + + {/if} + {/snippet} -
+
+
@@ -60,6 +77,7 @@ 0} + {scope} onItemClick={handleItemClick} /> {:else}