interface Entry { promise: Promise; fetchedAt: number; fetcher?: () => Promise; ttl?: number; } const store = new Map>(); const subs = new Map void>>(); const keyToGroups = new Map>(); const groups = new Map>(); export const DEFAULT_TTL_MS = 5 * 60 * 1_000; function notify(key: string) { subs.get(key)?.forEach(cb => cb()); } function registerGroups(key: string, group?: string | string[]) { if (!group) return; for (const tag of Array.isArray(group) ? group : [group]) { if (!groups.has(tag)) groups.set(tag, new Set()); groups.get(tag)!.add(key); if (!keyToGroups.has(key)) keyToGroups.set(key, new Set()); keyToGroups.get(key)!.add(tag); } } function unregisterKey(key: string) { const tags = keyToGroups.get(key); if (tags) { for (const tag of tags) groups.get(tag)?.delete(key); keyToGroups.delete(key); } } export const cache = { get(key: string, fetcher: () => Promise, ttl = DEFAULT_TTL_MS, group?: string | string[]): Promise { const existing = store.get(key) as Entry | undefined; if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise; const promise = fetcher().catch(err => { if (err?.name !== "AbortError") store.delete(key); return Promise.reject(err); }) as Promise; store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise, ttl }); registerGroups(key, group); promise.then(() => notify(key)).catch(() => {}); return promise; }, set(key: string, value: T, group?: string | string[]) { const existing = store.get(key) as Entry | undefined; store.set(key, { promise: Promise.resolve(value), fetchedAt: Date.now(), fetcher: existing?.fetcher, ttl: existing?.ttl, }); registerGroups(key, group); notify(key); }, update(key: string, fn: (prev: T) => T) { const existing = store.get(key) as Entry | undefined; if (!existing) return; const next = existing.promise.then(fn); store.set(key, { ...existing, promise: next, fetchedAt: Date.now() }); next.then(() => notify(key)).catch(() => {}); }, refresh(key: string): Promise | undefined { const existing = store.get(key) as Entry | undefined; if (!existing?.fetcher) return undefined; const promise = (existing.fetcher as () => Promise)().catch(err => { if (err?.name !== "AbortError") store.delete(key); return Promise.reject(err); }); store.set(key, { ...existing, promise: promise as Promise, fetchedAt: Date.now() }); promise.then(() => notify(key)).catch(() => {}); return promise; }, refreshGroup(tag: string): void { const keys = groups.get(tag); if (!keys) return; for (const key of [...keys]) { const existing = store.get(key); if (existing?.fetcher) { const promise = existing.fetcher().catch(err => { if (err?.name !== "AbortError") store.delete(key); return Promise.reject(err); }); store.set(key, { ...existing, promise, fetchedAt: Date.now() }); promise.then(() => notify(key)).catch(() => {}); } } }, has(key: string): boolean { return store.has(key); }, ageOf(key: string): number | undefined { const e = store.get(key); return e ? Date.now() - e.fetchedAt : undefined; }, isStale(key: string): boolean { const e = store.get(key); if (!e) return true; return Date.now() - e.fetchedAt >= (e.ttl ?? DEFAULT_TTL_MS); }, clear(key: string) { unregisterKey(key); store.delete(key); notify(key); }, clearGroup(tag: string) { const keys = groups.get(tag); if (!keys) return; for (const key of [...keys]) { keyToGroups.get(key)?.delete(tag); if (keyToGroups.get(key)?.size === 0) keyToGroups.delete(key); store.delete(key); notify(key); } groups.delete(tag); }, clearAll() { const allKeys = [...store.keys()]; store.clear(); groups.clear(); keyToGroups.clear(); allKeys.forEach(notify); }, subscribe(key: string, cb: () => void): () => void { if (!subs.has(key)) subs.set(key, new Set()); subs.get(key)!.add(cb); return () => subs.get(key)?.delete(cb); }, }; export const CACHE_GROUPS = { LIBRARY: "g:library", SOURCES: "g:sources", } as const; export const CACHE_KEYS = { LIBRARY: "library", RECENT_UPDATES: "recent_updates", ALL_MANGA: "all_manga_unfiltered", CATEGORIES: "categories", SEARCH: "search_all_manga", SOURCES: "sources", POPULAR: "popular", GENRE: (genre: string) => `genre:${genre}`, MANGA: (id: number) => `manga:${id}`, CHAPTERS: (id: number) => `chapters:${id}`, sourceMangaPages(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): string { const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? ""); return `pages:${sourceId}:${type}:${q}`; }, sourceMangaPage(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", page: number, query?: string | string[]): string { const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? ""); return `page:${sourceId}:${type}:${page}:${q}`; }, } as const; const inflight = new Map>(); export function deduped(key: string, fetcher: () => Promise): Promise { if (inflight.has(key)) return inflight.get(key) as Promise; const p = fetcher().finally(() => inflight.delete(key)); inflight.set(key, p); return p; } const _pageSets = new Map>(); export interface PageSet { add(page: number): void; pages(): Set; next(): number; clear(): void; } export function getPageSet(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): PageSet { const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query); return { add(page) { if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page); }, pages() { return new Set(_pageSets.get(key) ?? []); }, next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; }, clear() { _pageSets.delete(key); }, }; } const FRECENCY_KEY = "moku-source-frecency"; const MAX_FRECENCY_SOURCES = 4; type FrecencyMap = Record; function loadFrecency(): FrecencyMap { try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; } catch { return {}; } } function saveFrecency(map: FrecencyMap) { try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {} } export function recordSourceAccess(sourceId: string) { if (!sourceId || sourceId === "0") return; const map = loadFrecency(); map[sourceId] = (map[sourceId] ?? 0) + 1; saveFrecency(map); } export function getTopSources(sources: T[]): T[] { const map = loadFrecency(); const withScore = sources.map(s => ({ s, score: map[s.id] ?? 0 })); if (withScore.some(x => x.score > 0)) { return withScore.sort((a, b) => b.score - a.score).slice(0, MAX_FRECENCY_SOURCES).map(x => x.s); } return sources.slice(0, MAX_FRECENCY_SOURCES); } export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise { const didRefresh = cache.refresh(CACHE_KEYS.MANGA(mangaId)); if (!didRefresh) cache.clear(CACHE_KEYS.MANGA(mangaId)); cache.clear(CACHE_KEYS.CHAPTERS(mangaId)); cache.clear(CACHE_KEYS.LIBRARY); cache.clear(CACHE_KEYS.ALL_MANGA); if (thumbnailUrl) { const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache"); revokeBlobUrl(thumbnailUrl); getBlobUrl(thumbnailUrl, 999).catch(() => {}); } }