mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[V1] Major Revisions to Search & New Preview + Genre Filter (WIP Commit)
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Session-level request cache.
|
||||
*
|
||||
* Key design decisions:
|
||||
* - Stores the Promise itself — concurrent callers await the same fetch (no thundering herd).
|
||||
* - On real errors the entry is evicted so the next call retries.
|
||||
* - AbortErrors do NOT evict — the request was cancelled by the user, not failed.
|
||||
* This is critical: if we evicted on abort, rapid open/close would drain the browser's
|
||||
* connection pool (Chromium allows only 6 concurrent connections to the same origin).
|
||||
* - Subscribers are notified when a key is explicitly cleared (for reactive invalidation).
|
||||
*/
|
||||
const store = new Map<string, Promise<unknown>>();
|
||||
const subs = new Map<string, Set<() => void>>();
|
||||
|
||||
export const cache = {
|
||||
get<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
if (!store.has(key)) {
|
||||
store.set(key, fetcher().catch((err) => {
|
||||
// Only evict on real failures, not user cancellations
|
||||
if (err?.name !== "AbortError") store.delete(key);
|
||||
return Promise.reject(err);
|
||||
}));
|
||||
}
|
||||
return store.get(key) as Promise<T>;
|
||||
},
|
||||
has(key: string): boolean { return store.has(key); },
|
||||
clear(key: string) {
|
||||
store.delete(key);
|
||||
subs.get(key)?.forEach((cb) => cb());
|
||||
},
|
||||
clearAll() {
|
||||
store.clear();
|
||||
subs.forEach((set) => set.forEach((cb) => cb()));
|
||||
},
|
||||
/** Subscribe to cache invalidation for a key. Returns unsubscribe fn. */
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
// ── Cache key constants — single source of truth, prevents mismatches ─────────
|
||||
export const CACHE_KEYS = {
|
||||
LIBRARY: "library",
|
||||
SOURCES: "sources",
|
||||
POPULAR: "popular",
|
||||
GENRE: (genre: string) => `genre:${genre}`,
|
||||
MANGA: (id: number) => `manga:${id}`,
|
||||
CHAPTERS: (id: number) => `chapters:${id}`,
|
||||
} as const;
|
||||
|
||||
// ── In-flight request deduplication (for non-cached calls) ────────────────────
|
||||
//
|
||||
// Some requests (chapter lists, manga detail) are NOT stored in the long-lived
|
||||
// cache but still get fired multiple times when a user rapidly opens/closes a
|
||||
// manga. This map deduplicates them so only one network round-trip is active at
|
||||
// a time per key — regardless of how many components request it simultaneously.
|
||||
//
|
||||
const inflight = new Map<string, Promise<unknown>>();
|
||||
|
||||
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
|
||||
const p = fetcher().finally(() => inflight.delete(key));
|
||||
inflight.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ── Source frecency helpers ────────────────────────────────────────────────────
|
||||
|
||||
const FRECENCY_KEY = "moku-source-frecency";
|
||||
const MAX_FRECENCY_SOURCES = 4;
|
||||
|
||||
type FrecencyMap = Record<string, number>;
|
||||
|
||||
function loadFrecency(): FrecencyMap {
|
||||
try {
|
||||
const raw = localStorage.getItem(FRECENCY_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} 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<T extends { id: string }>(sources: T[]): T[] {
|
||||
const map = loadFrecency();
|
||||
const withScore = sources.map((s) => ({ s, score: map[s.id] ?? 0 }));
|
||||
const hasFrecency = withScore.some((x) => x.score > 0);
|
||||
|
||||
if (hasFrecency) {
|
||||
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);
|
||||
}
|
||||
+53
-14
@@ -1,7 +1,6 @@
|
||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||
|
||||
function getServerUrl(): string {
|
||||
// Read from persisted Zustand store if available, fall back to default
|
||||
try {
|
||||
const raw = localStorage.getItem("moku-store");
|
||||
if (raw) {
|
||||
@@ -26,15 +25,55 @@ interface GQLResponse<T> {
|
||||
errors?: { message: string }[];
|
||||
}
|
||||
|
||||
// Retry with exponential backoff — Suwayomi may not be ready on first load
|
||||
async function fetchWithRetry(url: string, init: RequestInit, retries = 8, delayMs = 500): Promise<Response> {
|
||||
/** Sleep that resolves early if the signal is aborted — never blocks a cancelled request. */
|
||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry wrapper with these guarantees:
|
||||
* 1. AbortErrors always propagate immediately — no retry, no delay.
|
||||
* 2. Retry delays are abort-aware — closing a manga mid-delay doesn't hang.
|
||||
* 3. If the signal is already aborted before we even start, we bail instantly.
|
||||
*/
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
retries = 3,
|
||||
delayMs = 300,
|
||||
): Promise<Response> {
|
||||
// Bail immediately if already aborted before we start
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
// Check abort at the top of every iteration
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
try {
|
||||
const res = await fetch(url, init);
|
||||
const res = await fetch(url, { ...init, signal });
|
||||
|
||||
// Check abort again — fetch can return a response even after abort in some runtimes
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
return res;
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
// Never retry aborted requests
|
||||
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
||||
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
// Last retry — give up
|
||||
if (i === retries - 1) throw e;
|
||||
await new Promise((r) => setTimeout(r, delayMs * Math.pow(1.5, i)));
|
||||
|
||||
// Abort-aware delay between retries
|
||||
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
@@ -42,23 +81,23 @@ async function fetchWithRetry(url: string, init: RequestInit, retries = 8, delay
|
||||
|
||||
export async function gql<T>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>
|
||||
variables?: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const res = await fetchWithRetry(gqlUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
}, signal);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||
}
|
||||
// Check abort before reading the body — avoids hanging on res.json() after cancel
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||
|
||||
const json: GQLResponse<T> = await res.json();
|
||||
|
||||
if (json.errors?.length) {
|
||||
throw new Error(json.errors[0].message);
|
||||
}
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||
|
||||
return json.data;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Source } from "./types";
|
||||
|
||||
/**
|
||||
* Deduplicates sources by name, preferring the given language.
|
||||
* This prevents fetching MangaDex EN + MangaDex ES + MangaDex FR separately.
|
||||
*/
|
||||
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||
const byName = new Map<string, Source[]>();
|
||||
for (const src of sources) {
|
||||
if (src.id === "0") continue;
|
||||
if (!byName.has(src.name)) byName.set(src.name, []);
|
||||
byName.get(src.name)!.push(src);
|
||||
}
|
||||
const picked: Source[] = [];
|
||||
for (const group of byName.values()) {
|
||||
const preferred = group.find((s) => s.lang === preferredLang);
|
||||
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates manga by title (case-insensitive), keeping the first occurrence.
|
||||
* This eliminates the same series appearing from multiple sources in grids.
|
||||
*/
|
||||
export function dedupeMangaByTitle<T extends { id: number; title: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
const out: T[] = [];
|
||||
for (const m of items) {
|
||||
const key = m.title.toLowerCase().trim();
|
||||
if (!seen.has(key)) { seen.add(key); out.push(m); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates manga by id only (lossless — use when sources are already deduped).
|
||||
*/
|
||||
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
||||
const seen = new Set<number>();
|
||||
const out: T[] = [];
|
||||
for (const m of items) {
|
||||
if (!seen.has(m.id)) { seen.add(m.id); out.push(m); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user