import type { Manga, Chapter } from '$lib/types' import type { BookmarkEntry, MarkerEntry, MarkerColor } from '$lib/types/history' import type { MangaPrefs } from '$lib/types/settings' import { settingsState, updateSettings } from '$lib/state/settings.svelte' import { getAdapter } from '$lib/request-manager' import { buildChapterList } from '$lib/components/series/lib/chapterList' import { goto } from '$app/navigation' export type { BookmarkEntry, MarkerEntry, MarkerColor } from '$lib/types/history' export type { MangaPrefs } from '$lib/types/settings' export const DEFAULT_MANGA_PREFS: MangaPrefs = { sortMode: 'source', sortDir: 'asc', preferredScanlator: '', scanlatorFilter: [], scanlatorBlacklist: [], scanlatorForce: false, autoDownload: false, downloadAhead: 0, maxKeepChapters: 0, deleteOnRead: false, deleteDelayHours: 0, pauseUpdates: false, refreshInterval: 'global', coverUrl: '', } const CHAPTER_TTL_MS = 2 * 60 * 1000 class SeriesStore { activeManga = $state(null) previewManga = $state(null) activeChapter = $state(null) bookmarks = $state([]) markers = $state([]) acknowledgedUpdates = $state>(new Set()) #rawChapters = $state>(new Map()) #fetchedAt = new Map() #abortCtrls = new Map() #loading = $state>(new Set()) #errors = $state>(new Map()) readonly activeChapterList = $derived.by(() => { const id = this.activeManga?.id if (id == null) return [] const raw = this.#rawChapters.get(id) ?? [] const prefs = settingsState.settings.mangaPrefs?.[id] ?? {} return buildChapterList(raw, { sortMode: (prefs.sortMode ?? DEFAULT_MANGA_PREFS.sortMode) as MangaPrefs['sortMode'], sortDir: (prefs.sortDir ?? DEFAULT_MANGA_PREFS.sortDir) as MangaPrefs['sortDir'], preferredScanlator: (prefs.preferredScanlator ?? DEFAULT_MANGA_PREFS.preferredScanlator) as string, scanlatorFilter: (prefs.scanlatorFilter ?? DEFAULT_MANGA_PREFS.scanlatorFilter) as string[], scanlatorBlacklist: (prefs.scanlatorBlacklist ?? DEFAULT_MANGA_PREFS.scanlatorBlacklist) as string[], scanlatorForce: (prefs.scanlatorForce ?? DEFAULT_MANGA_PREFS.scanlatorForce) as boolean, }) }) readonly readerChapterList = $derived.by(() => { const id = this.activeManga?.id if (id == null) return [] const raw = this.#rawChapters.get(id) ?? [] const prefs = settingsState.settings.mangaPrefs?.[id] ?? {} return buildChapterList(raw, { sortMode: 'source', sortDir: 'asc', preferredScanlator: (prefs.preferredScanlator ?? DEFAULT_MANGA_PREFS.preferredScanlator) as string, scanlatorFilter: (prefs.scanlatorFilter ?? DEFAULT_MANGA_PREFS.scanlatorFilter) as string[], scanlatorBlacklist: (prefs.scanlatorBlacklist ?? DEFAULT_MANGA_PREFS.scanlatorBlacklist) as string[], scanlatorForce: (prefs.scanlatorForce ?? DEFAULT_MANGA_PREFS.scanlatorForce) as boolean, }) }) chaptersFor(mangaId: number): Chapter[] { return this.#rawChapters.get(mangaId) ?? [] } isLoadingChapters(mangaId: number) { return this.#loading.has(mangaId) } chapterError(mangaId: number) { return this.#errors.get(mangaId) ?? null } async loadChapters(mangaId: number, { force = false } = {}): Promise { const now = Date.now() const stalest = this.#fetchedAt.get(mangaId) ?? 0 const fresh = !force && this.#rawChapters.has(mangaId) && now - stalest < CHAPTER_TTL_MS if (fresh) return this.#abortCtrls.get(mangaId)?.abort() const ctrl = new AbortController() this.#abortCtrls.set(mangaId, ctrl) this.#loading = new Set([...this.#loading, mangaId]) this.#errors = new Map(this.#errors) this.#errors.delete(mangaId) try { const adapter = getAdapter() let nodes = await adapter.getChapters(String(mangaId), ctrl.signal) if (!ctrl.signal.aborted && nodes.length === 0) { const fetched = await adapter.fetchChapters(String(mangaId), ctrl.signal) if (!ctrl.signal.aborted) nodes = fetched } if (ctrl.signal.aborted) return this.#rawChapters = new Map(this.#rawChapters).set(mangaId, nodes) this.#fetchedAt.set(mangaId, Date.now()) } catch (e: unknown) { if ((e as { name?: string }).name === 'AbortError') return const msg = e instanceof Error ? e.message : String(e) this.#errors = new Map(this.#errors).set(mangaId, msg) } finally { if (!ctrl.signal.aborted) { const next = new Set(this.#loading) next.delete(mangaId) this.#loading = next } } } invalidateChapters(mangaId: number) { this.#fetchedAt.delete(mangaId) } patchChapters(mangaId: number, updater: (chapters: Chapter[]) => Chapter[]) { const current = this.#rawChapters.get(mangaId) if (!current) return this.#rawChapters = new Map(this.#rawChapters).set(mangaId, updater(current)) } setActiveManga(manga: Manga | null) { this.activeManga = manga } setPreviewManga(manga: Manga | null) { this.previewManga = manga } openReaderForChapter(chapter: Chapter, manga?: Manga | null) { if (manga !== undefined) this.activeManga = manga const mangaId = this.activeManga?.id if (!mangaId) return const list = this.readerChapterList const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {} const ahead = (prefs.downloadAhead ?? DEFAULT_MANGA_PREFS.downloadAhead) as number if (ahead > 0) { const idx = list.findIndex(c => c.id === chapter.id) if (idx >= 0) { const toQueue = list .slice(idx + 1, idx + 1 + ahead) .filter(c => !c.downloaded && !c.read) .map(c => String(c.id)) if (toQueue.length) getAdapter().enqueueDownloads(toQueue).catch(console.error) } } this.activeChapter = chapter goto(`/reader/${mangaId}/${chapter.id}`) } closeReader() { this.activeChapter = null } acknowledgeUpdate(mangaId: number) { if (this.acknowledgedUpdates.has(mangaId)) return this.acknowledgedUpdates = new Set([...this.acknowledgedUpdates, mangaId]) } getPref(mangaId: number, key: K): MangaPrefs[K] { const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {} return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K] } setPref(mangaId: number, key: K, value: MangaPrefs[K]) { updateSettings({ mangaPrefs: { ...settingsState.settings.mangaPrefs, [mangaId]: { ...(settingsState.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value }, }, }) } addBookmark(entry: Omit, label?: string) { this.bookmarks = [ { ...entry, savedAt: Date.now(), label }, ...this.bookmarks.filter(b => b.chapterId !== entry.chapterId), ].slice(0, 200) } removeBookmark(chapterId: number) { this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId) } clearBookmarks() { this.bookmarks = [] } getBookmark(chapterId: number) { return this.bookmarks.find(b => b.chapterId === chapterId) } addMarker(entry: Omit): string { const id = Math.random().toString(36).slice(2) this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }] return id } updateMarker(id: string, patch: Partial>) { this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m) } removeMarker(id: string) { this.markers = this.markers.filter(m => m.id !== id) } getMarkersForPage(chapterId: number, page: number) { return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page) } getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId) } getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId) } clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId) } get settings() { return settingsState.settings } } export const seriesState = new SeriesStore() export const seriesStore = seriesState export function setActiveManga(next: Manga | null) { seriesState.setActiveManga(next) } export function setPreviewManga(next: Manga | null) { seriesState.setPreviewManga(next) } export function openReaderForChapter(ch: Chapter, manga?: Manga | null) { seriesState.openReaderForChapter(ch, manga) } export function closeReader() { seriesState.closeReader() } export function acknowledgeUpdate(mangaId: number) { seriesState.acknowledgeUpdate(mangaId) } export function addBookmark(entry: Omit, label?: string) { seriesState.addBookmark(entry, label) } export function removeBookmark(chapterId: number) { seriesState.removeBookmark(chapterId) } export function clearBookmarks() { seriesState.clearBookmarks() } export function getBookmark(chapterId: number) { return seriesState.getBookmark(chapterId) } export function addMarker(entry: Omit): string { return seriesState.addMarker(entry) } export function updateMarker(id: string, patch: Partial>) { seriesState.updateMarker(id, patch) } export function removeMarker(id: string) { seriesState.removeMarker(id) } export function getMarkersForPage(chapterId: number, page: number) { return seriesState.getMarkersForPage(chapterId, page) } export function getMarkersForChapter(chapterId: number) { return seriesState.getMarkersForChapter(chapterId) } export function getMarkersForManga(mangaId: number) { return seriesState.getMarkersForManga(mangaId) } export function clearMarkersForManga(mangaId: number) { seriesState.clearMarkersForManga(mangaId) } export function getPref(mangaId: number, key: K): MangaPrefs[K] { return seriesState.getPref(mangaId, key) } export function setPref(mangaId: number, key: K, v: MangaPrefs[K]) { seriesState.setPref(mangaId, key, v) }