368 lines
12 KiB
TypeScript
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);
|