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:
@@ -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
|
||||
]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user