jellytau/src/lib/stores/library.ts

368 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 { 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.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<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.
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);