feat(search): context-scoped search with filter chips and group order

Add a search scope (all/music/shows/movies) resolved from the entry
route and adjustable via filter chips, threaded through the library
store's search() into includeItemTypes. Results group by type in a
user-configurable order, editable from settings.

TRACES: UR-049 | DR-063, DR-064, DR-065; UR-050 | DR-066, DR-067
This commit is contained in:
2026-07-23 20:02:15 +02:00
parent e083b53ee8
commit c175378f38
11 changed files with 1135 additions and 256 deletions
+19 -2
View File
@@ -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
]);
+122
View File
@@ -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);
});
});
+136
View File
@@ -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<string, string>();
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",
]);
});
});
+77
View File
@@ -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<SearchGroupId[]>(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();