Files
jellytau/src/lib/stores/library.ts
dtourolle 105cc082ea fix(search): move scope→item-type taxonomy into Rust (UR-049, DR-063)
Stage 1 of scoped-search-boundary-implementation.md — the query side.

scoped-search-boundary.md diagnosed this leak, specified the fix in
detail, and became the justification for the boundary rule in CLAUDE.md,
the check:boundary tripwire, and the spec-review checklist. The fix was
never built: SCOPE_ITEM_TYPES was still live in searchScope.ts, called by
library.ts, and no SearchScope existed anywhere in src-tauri/. The rule's
own founding violation was still shipping.

Rust now owns the taxonomy:

  pub enum SearchScope { All, Music, Movies, Tv }
  impl SearchScope { pub fn item_types(self) -> Option<Vec<String>> }

- SearchOptions gains `scope`, resolved by resolve_scope(). Scope wins
  over include_item_types, which stays for the non-search get_items
  callers that legitimately request one concrete type.
- repository_search resolves the scope ONCE, before the cache/server
  paths diverge, so online and offline filter identically — the failure
  mode most likely to go unnoticed.
- All expands to None (no filter), not the union of the other scopes:
  an explicit includeItemTypes list would silently drop People, folders,
  and any type nobody enumerated.
- searchScope.ts re-exports SearchScope from generated bindings instead
  of a hand-written union, and no longer names an item type for search.
- library.ts sends { scope }.

8 Rust tests written first, confirmed failing on "use of undeclared type
SearchScope" before the implementation existed.

The frontend tests that asserted includeItemTypes contents were rewritten
to assert the opaque scope is sent and includeItemTypes is absent —
keeping the old assertions would require the frontend to know the
taxonomy again, defeating the fix. The expansion is now asserted in Rust.

Verified the spec's headline criterion by hashing every src/ file, adding
"AudioBook" to the Music scope in Rust, and re-hashing: zero frontend
files change. That criterion failed before this commit.

Stage 2 (result-side grouping: GROUP_ITEM_TYPES, GroupedSearchResult on
both search payloads) remains open.
2026-07-30 10:30:38 +02:00

385 lines
12 KiB
TypeScript

// Library state store
// TRACES: UR-007, UR-008, UR-029, UR-030 | DR-007, DR-011, DR-033
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 type { SearchScope } from "$lib/utils/searchScope";
import { auth } from "./auth";
/**
* Payload of the backend `search-event` (mirrors Rust `SearchUpdateEvent`).
* Carries the merged cache+server results for a given search request.
*/
interface SearchUpdateEvent {
requestId: number;
result: SearchResult;
}
export type ViewMode = "grid" | "list";
interface LibraryState {
libraries: Library[];
currentLibrary: Library | null;
items: MediaItem[];
currentItem: MediaItem | null;
/** Counter for concurrent loading operations. isLoading = loadingCount > 0 */
loadingCount: number;
error: string | null;
totalItems: number;
searchQuery: string;
searchResults: MediaItem[];
viewMode: ViewMode;
genres: Genre[];
selectedGenres: string[];
}
function getStoredViewMode(): ViewMode {
if (typeof localStorage === "undefined") return "grid";
const stored = localStorage.getItem("jellytau-view-mode");
return stored === "list" ? "list" : "grid";
}
function createLibraryStore() {
const initialState: LibraryState = {
libraries: [],
currentLibrary: null,
items: [],
currentItem: null,
loadingCount: 0,
error: null,
totalItems: 0,
searchQuery: "",
searchResults: [],
viewMode: getStoredViewMode(),
genres: [],
selectedGenres: [],
};
const { subscribe, set, update } = writable<LibraryState>(initialState);
// Monotonic id identifying the most recent search request. Each new search
// bumps it; the deferred `search-event` (carrying merged cache+server
// results) is only applied when its requestId still matches the latest one,
// so out-of-order / superseded results never clobber fresher ones.
let searchRequestId = 0;
let unlistenSearch: UnlistenFn | null = null;
// Lazily subscribe to backend search updates the first time we search.
async function ensureSearchListener() {
if (unlistenSearch) return;
unlistenSearch = await listen<SearchUpdateEvent>("search-event", (event) => {
const { requestId, result } = event.payload;
// Ignore results from a query the user has already moved on from.
if (requestId !== searchRequestId) return;
update((s) => ({ ...s, searchResults: result.items }));
});
}
async function loadLibraries() {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
try {
const startTime = performance.now();
const repo = auth.getRepository();
console.log("📚 [LibraryStore] Loading libraries...");
const libraries = await repo.getLibraries();
const loadTime = Math.round(performance.now() - startTime);
if (loadTime < 100) {
console.log(`🚀 [LibraryStore] CACHE HIT! Loaded ${libraries.length} libraries in ${loadTime}ms (instant)`);
} else {
console.log(`⏳ [LibraryStore] Loaded ${libraries.length} libraries in ${loadTime}ms (from server)`);
}
update((s) => ({
...s,
libraries,
loadingCount: Math.max(0, s.loadingCount - 1),
}));
return libraries;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load libraries";
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error;
}
}
async function loadItems(
parentId: string,
options: { startIndex?: number; limit?: number; genres?: string[] } = {}
) {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
try {
const startTime = performance.now();
const repo = auth.getRepository();
console.log(`📚 [LibraryStore] Loading items for parent: ${parentId.substring(0, 8)}...`);
const result = await repo.getItems(parentId, {
startIndex: options.startIndex ?? 0,
limit: options.limit ?? 10000,
fields: ["PrimaryImageAspectRatio", "Overview", "MediaStreams"],
sortBy: "SortName",
sortOrder: "Ascending",
genres: options.genres,
});
const loadTime = Math.round(performance.now() - startTime);
if (loadTime < 100) {
console.log(`🚀 [LibraryStore] CACHE HIT! Loaded ${result.items.length} items in ${loadTime}ms (instant)`);
} else {
console.log(`⏳ [LibraryStore] Loaded ${result.items.length} items in ${loadTime}ms (from server)`);
}
update((s) => ({
...s,
items: result.items,
totalItems: result.totalRecordCount,
loadingCount: Math.max(0, s.loadingCount - 1),
}));
return result;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load items";
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error;
}
}
// Load Live TV channels (broadcast / IPTV) into the items list. Live TV uses a
// dedicated Jellyfin endpoint rather than the generic /Items browse.
async function loadLiveTvChannels() {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
try {
const repo = auth.getRepository();
const channels = await repo.getLiveTvChannels();
update((s) => ({
...s,
items: channels,
totalItems: channels.length,
loadingCount: Math.max(0, s.loadingCount - 1),
}));
return channels;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load Live TV channels";
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error;
}
}
// Load the root list of plugin "Channels" into the items list.
async function loadChannels() {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
try {
const repo = auth.getRepository();
const result = await repo.getChannels();
update((s) => ({
...s,
items: result.items,
totalItems: result.totalRecordCount,
loadingCount: Math.max(0, s.loadingCount - 1),
}));
return result;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load channels";
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error;
}
}
async function loadItem(itemId: string) {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
try {
const repo = auth.getRepository();
const item = await repo.getItem(itemId);
console.log(`[LibraryStore] loadItem(${itemId}): ${item.name} (${item.kind})`);
console.log(`[LibraryStore] - Has people? ${item.people ? `YES (${item.people.length})` : 'NO'}`);
if (item.people && item.people.length > 0) {
item.people.forEach((p, i) => {
console.log(`[LibraryStore] [${i}] ${p.name} (type: "${p.type}", id: "${p.id}")`);
});
}
update((s) => ({
...s,
currentItem: item,
loadingCount: Math.max(0, s.loadingCount - 1),
}));
return item;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load item";
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error;
}
}
/**
* Search the library, optionally narrowed to a scope.
*
* The scope is sent **opaque**; Rust expands it into Jellyfin item types
* (`SearchScope::item_types()`) before the cache and server paths diverge, so
* online and offline results filter identically. `all` resolves to no filter
* at all — not the union of the other scopes, which would drop People and
* folders.
*
* TRACES: UR-049 | DR-063, 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;
if (!query.trim()) {
update((s) => ({ ...s, searchQuery: "", searchResults: [] }));
return;
}
await ensureSearchListener();
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null, searchQuery: query }));
try {
const repo = auth.getRepository();
// Add 10-second timeout to prevent indefinite hanging
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Search timeout - please try again")), 10000)
);
// 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.
// Send the opaque scope; Rust expands it to item types. The frontend
// never names a Jellyfin item type in connection with search.
const options: SearchOptions = { limit: 10000, scope };
const result = await Promise.race([
repo.search(query, options, requestId),
timeoutPromise
]);
// Only apply if this is still the active query (a newer search may have
// started while we awaited).
if (requestId === searchRequestId) {
update((s) => ({ ...s, searchResults: result.items }));
}
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1) }));
return result;
} catch (error) {
const message = error instanceof Error ? error.message : "Search failed";
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error;
}
}
function setCurrentLibrary(library: Library | null) {
update((s) => ({ ...s, currentLibrary: library, items: [], currentItem: null }));
}
function clearSearch() {
// Invalidate any in-flight backend search update.
searchRequestId++;
update((s) => ({ ...s, searchQuery: "", searchResults: [] }));
}
function setViewMode(mode: ViewMode) {
if (typeof localStorage !== "undefined") {
localStorage.setItem("jellytau-view-mode", mode);
}
update((s) => ({ ...s, viewMode: mode }));
}
function toggleViewMode() {
update((s) => {
const newMode = s.viewMode === "grid" ? "list" : "grid";
if (typeof localStorage !== "undefined") {
localStorage.setItem("jellytau-view-mode", newMode);
}
return { ...s, viewMode: newMode };
});
}
async function loadGenres(parentId?: string) {
try {
const repo = auth.getRepository();
const genres = await repo.getGenres(parentId);
update((s) => ({ ...s, genres }));
return genres;
} catch (error) {
console.error("Failed to load genres:", error);
return [];
}
}
function setSelectedGenres(genres: string[]) {
update((s) => ({ ...s, selectedGenres: genres }));
}
function toggleGenre(genreName: string) {
update((s) => {
const current = s.selectedGenres;
const newGenres = current.includes(genreName)
? current.filter((g) => g !== genreName)
: [...current, genreName];
return { ...s, selectedGenres: newGenres };
});
}
function clearGenres() {
update((s) => ({ ...s, selectedGenres: [] }));
}
function reset() {
searchRequestId++;
if (unlistenSearch) {
unlistenSearch();
unlistenSearch = null;
}
set(initialState);
}
return {
subscribe,
loadLibraries,
loadItems,
loadLiveTvChannels,
loadChannels,
loadItem,
search,
setCurrentLibrary,
clearSearch,
setViewMode,
toggleViewMode,
loadGenres,
setSelectedGenres,
toggleGenre,
clearGenres,
reset,
};
}
export const library = createLibraryStore();
// Derived stores
export const libraries = derived(library, ($lib) => $lib.libraries);
export const currentLibrary = derived(library, ($lib) => $lib.currentLibrary);
export const libraryItems = derived(library, ($lib) => $lib.items);
export const isLibraryLoading = derived(library, ($lib) => $lib.loadingCount > 0);
export const libraryError = derived(library, ($lib) => $lib.error);
export const viewMode = derived(library, ($lib) => $lib.viewMode);
export const genres = derived(library, ($lib) => $lib.genres);
export const selectedGenres = derived(library, ($lib) => $lib.selectedGenres);