// 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 { 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(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("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.type})`); 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; } } async function search(query: string) { // 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((_, 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. const result = await Promise.race([ repo.search(query, { limit: 10000 }, 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);