Fix: Discover V2 + Windows Update System (Testing)

This commit is contained in:
Youwes09
2026-03-22 14:36:11 -05:00
parent 4691f3aed7
commit a3ef693ed8
21 changed files with 1417 additions and 186 deletions
+92 -113
View File
@@ -5,18 +5,18 @@
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util";
import { store, addFolder, assignMangaToFolder, setPreviewManga } from "../../store/state.svelte";
import { store, addFolder, assignMangaToFolder, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
import type { Manga, Source } from "../../lib/types";
import ContextMenu from "../shared/ContextMenu.svelte";
import type { MenuEntry } from "../shared/ContextMenu.svelte";
import SourceBrowse from "../shared/SourceBrowse.svelte";
// ── Constants ─────────────────────────────────────────────────────────────
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
const GRID_LIMIT = 100;
const LOCAL_THRESHOLD = 20;
const CONCURRENCY = 4;
const BATCH_MS = 400;
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
const GRID_LIMIT = 200;
const CONCURRENCY = 6;
const PAGES_INIT = 3; // pages per source on All tab
const PAGES_GENRE = 2; // pages per source on genre tabs
const EXPLORE_ALL_MANGA = `
query ExploreAllManga {
@@ -33,28 +33,20 @@
}
`;
// ── Dedicated discover cache ───────────────────────────────────────────────
// Completely isolated from main app cache — refresh only wipes this,
// leaving library/chapter/source caches untouched.
const discoverStore = new Map<string, Manga[]>();
function dKey(srcId: string, type: string, tag: string) { return `${srcId}|${type}|${tag}`; }
function clearDiscover() { discoverStore.clear(); }
function dKey(srcId: string, type: string, genre: string, page: number) {
return `${srcId}|${type}|${genre}:p${page}`;
}
// ── State ─────────────────────────────────────────────────────────────────
let allManga: Manga[] = $state([]);
let allSources: Source[] = $state([]);
let libraryIds: Set<number> = $state(new Set());
let loadingLib = $state(true);
let loadError = $state(false);
let currentGenre = $state("All");
let genreResults = $state(new Map<string, Manga[]>());
let genreLoading = $state(false);
let srcOffset = $state(0);
// ── Local component state ─────────────────────────────────────────────────
let allSources: Source[] = $state([]);
let loadingLib = $state(true);
let loadError = $state(false);
let currentGenre = $state("All");
let genreResults = $state(new Map<string, Manga[]>());
let genreLoading = $state(false);
let refreshing = $state(false);
let activeCtrl: AbortController | null = null;
let batchTimer: ReturnType<typeof setInterval> | null = null;
let batchAccum = new Map<string, Manga[]>();
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
@@ -65,15 +57,15 @@
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
}
function filterSource(mangas: Manga[]): Manga[] {
return dedup(mangas.filter(m => !m.inLibrary && !libraryIds.has(m.id)));
function filterOut(mangas: Manga[]): Manga[] {
return dedup(mangas.filter(m => !m.inLibrary && !store.discoverLibraryIds.has(m.id)));
}
function rotatedSources(): Source[] {
const lang = store.settings.preferredExtensionLang || "en";
const srcs = dedupeSources(allSources.filter(s => s.id !== "0"), lang);
if (!srcs.length) return [];
const off = srcOffset % srcs.length;
const off = store.discoverSrcOffset % srcs.length;
return [...srcs.slice(off), ...srcs.slice(0, off)];
}
@@ -88,80 +80,64 @@
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
}
// ── Batch flush ───────────────────────────────────────────────────────────
function startBatch() {
if (batchTimer) return;
batchTimer = setInterval(() => {
if (!batchAccum.size) return;
for (const [genre, incoming] of batchAccum) {
const cur = genreResults.get(genre) ?? [];
genreResults.set(genre, dedup([...cur, ...incoming]).slice(0, GRID_LIMIT));
}
batchAccum.clear();
genreResults = new Map(genreResults);
}, BATCH_MS);
}
function flushBatch() {
if (batchTimer) { clearInterval(batchTimer); batchTimer = null; }
if (!batchAccum.size) return;
for (const [genre, incoming] of batchAccum) {
const cur = genreResults.get(genre) ?? [];
genreResults.set(genre, dedup([...cur, ...incoming]).slice(0, GRID_LIMIT));
}
batchAccum.clear();
genreResults = new Map(genreResults);
}
function accumulate(genre: string, mangas: Manga[]) {
const filtered = filterSource(mangas);
// Push results into the reactive grid immediately — no batch delay.
function pushToGrid(genre: string, incoming: Manga[]) {
const filtered = filterOut(incoming);
if (!filtered.length) return;
const existing = batchAccum.get(genre) ?? [];
batchAccum.set(genre, [...existing, ...filtered]);
const cur = genreResults.get(genre) ?? [];
genreResults.set(genre, dedup([...cur, ...filtered]).slice(0, GRID_LIMIT));
genreResults = new Map(genreResults);
}
// ── Source fan-out ────────────────────────────────────────────────────────
async function fanOut(genre: string, ctrl: AbortController) {
const srcs = rotatedSources();
const srcs = rotatedSources();
if (!srcs.length) return;
const isAll = genre === "All";
const type = isAll ? "POPULAR" : "SEARCH";
const query = isAll ? null : genre;
startBatch();
const isAll = genre === "All";
const type = isAll ? "POPULAR" : "SEARCH";
const query = isAll ? null : genre;
const maxPages = isAll ? PAGES_INIT : PAGES_GENRE;
await runConcurrent(srcs, async src => {
if (ctrl.signal.aborted) return;
const key = dKey(src.id, type, genre);
for (let page = 1; page <= maxPages; page++) {
if (ctrl.signal.aborted) return;
let mangas: Manga[];
if (discoverStore.has(key)) {
mangas = discoverStore.get(key)!;
} else {
const result = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type, page: 1, query },
ctrl.signal
).then(d => d.fetchSourceManga).catch(() => null);
if (!result || ctrl.signal.aborted) return;
mangas = result.mangas;
discoverStore.set(key, mangas);
}
const key = dKey(src.id, type, genre, page);
let mangas: Manga[];
let hasNextPage = false;
if (ctrl.signal.aborted) return;
if (store.discoverCache.has(key)) {
// Cache hit — no network call needed
mangas = store.discoverCache.get(key)!;
} else {
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type, page, query },
ctrl.signal
).then(d => d.fetchSourceManga).catch(() => null);
if (isAll) {
accumulate("All", mangas);
} else {
const matching = mangas.filter(m =>
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
);
accumulate(genre, matching.length ? matching : mangas);
if (!result || ctrl.signal.aborted) return;
mangas = result.mangas;
hasNextPage = result.hasNextPage;
store.discoverCache.set(key, mangas);
}
if (ctrl.signal.aborted) return;
if (isAll) {
pushToGrid("All", mangas);
} else {
const matching = mangas.filter(m =>
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
);
pushToGrid(genre, matching.length ? matching : mangas);
}
// Stop paging early if source is exhausted
if (!hasNextPage) return;
}
}, ctrl.signal);
if (!ctrl.signal.aborted) flushBatch();
}
// ── Tab switch ────────────────────────────────────────────────────────────
@@ -169,13 +145,18 @@
if (currentGenre === genre) return;
activeCtrl?.abort();
flushBatch();
currentGenre = genre;
const ctrl = new AbortController();
activeCtrl = ctrl;
if (genre === "All") {
// Already have results from this session — show instantly, re-fan in background
if ((genreResults.get("All") ?? []).length > 0) {
genreLoading = false;
fanOut("All", ctrl).catch(() => {});
return;
}
genreResults.set("All", []);
genreResults = new Map(genreResults);
genreLoading = true;
@@ -184,18 +165,15 @@
return;
}
// Genre tab: check local cache first, always fan out to sources too
// Genre tab: serve cached local results instantly, always fan out too
const localKey = `local|${genre}`;
if (discoverStore.has(localKey)) {
// Serve cached local results immediately
genreResults.set(genre, discoverStore.get(localKey)!);
if (store.discoverCache.has(localKey)) {
genreResults.set(genre, store.discoverCache.get(localKey)!);
genreResults = new Map(genreResults);
// Always fan out in background to get source results too
fanOut(genre, ctrl).catch(() => {});
return;
}
// Fetch local library results then fan out
genreLoading = true;
try {
const d = await gql<{ mangas: { nodes: Manga[] } }>(
@@ -204,12 +182,11 @@
if (ctrl.signal.aborted) return;
const local = dedup(d.mangas.nodes);
discoverStore.set(localKey, local);
store.discoverCache.set(localKey, local);
genreResults.set(genre, local.slice(0, GRID_LIMIT));
genreResults = new Map(genreResults);
genreLoading = false;
// Always fan out — show source results alongside library results
fanOut(genre, ctrl).catch(() => {});
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
@@ -218,13 +195,9 @@
}
// ── Refresh ───────────────────────────────────────────────────────────────
let refreshing = $state(false);
async function refresh() {
activeCtrl?.abort();
flushBatch();
clearDiscover();
srcOffset++;
clearDiscoverCache(); // wipes store.discoverCache + bumps discoverSrcOffset
genreResults = new Map();
refreshing = true;
genreLoading = true;
@@ -240,23 +213,29 @@
loadingLib = true;
loadError = false;
// Load library for filtering — don't show stuff already in library
// Already have a session grid — show it immediately
if ((genreResults.get("All") ?? []).length > 0) {
loadingLib = false;
}
// Refresh library ID set so newly-added manga get filtered out
cache.get(CACHE_KEYS.DISCOVER, () =>
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
).then(m => {
allManga = dedupeMangaById(m);
libraryIds = new Set(allManga.filter(x => x.inLibrary).map(x => x.id));
store.discoverLibraryIds = new Set(
dedupeMangaById(m).filter(x => x.inLibrary).map(x => x.id)
);
}).catch(e => { console.error(e); loadError = true; })
.finally(() => { loadingLib = false; });
// Load sources then kick off initial All tab fan-out
// Load sources then kick off All tab fan-out (only if grid is empty)
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then(d => {
allSources = d.sources.nodes;
// Only trigger if still on All tab
if (currentGenre === "All" || currentGenre === "") {
const ctrl = new AbortController();
activeCtrl = ctrl;
if ((currentGenre === "All" || currentGenre === "") &&
(genreResults.get("All") ?? []).length === 0) {
const ctrl = new AbortController();
activeCtrl = ctrl;
genreLoading = true;
fanOut("All", ctrl).then(() => {
if (!ctrl.signal.aborted) genreLoading = false;
@@ -266,7 +245,7 @@
.catch(console.error);
}
onDestroy(() => { activeCtrl?.abort(); flushBatch(); });
onDestroy(() => { activeCtrl?.abort(); });
loadAll();
@@ -284,7 +263,7 @@
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => {
cache.clear(CACHE_KEYS.LIBRARY);
libraryIds = new Set([...libraryIds, m.id]);
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
}).catch(console.error),
},
...(store.settings.folders.length > 0 ? [
@@ -332,7 +311,7 @@
</div>
<div class="body">
{#if isLoading}
{#if isLoading && visibleGrid.length === 0}
<div class="manga-grid">
{#each Array(24) as _, i (i)}
<div class="card-skeleton"><div class="skeleton cover-area"></div></div>
+5 -2
View File
@@ -153,7 +153,7 @@
<input class="ext-input" class:error={installError} placeholder="https://example.com/extension.apk"
bind:value={externalUrl} disabled={installing}
oninput={() => installError = null}
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} autofocus />
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} use:focusOnMount />
<button class="install-btn" class:success={installSuccess} onclick={installExternal} disabled={installing || !externalUrl.trim()}>
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
{:else if installSuccess}<Check size={13} weight="bold" /> Done
@@ -328,7 +328,6 @@
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
.update-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 2px 6px; flex-shrink: 0; }
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
.row-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
@@ -346,3 +345,7 @@
.variant-actions { flex-shrink: 0; }
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
</style>
<script module>
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>
+3 -3
View File
@@ -35,9 +35,9 @@
let libraryManga: Manga[] = $state([]);
let sourceManga: Manga[] = $state([]);
let loadingInitial = true;
let loadingMore = false;
let visibleCount = PAGE_SIZE;
let loadingInitial = $state(true);
let loadingMore = $state(false);
let visibleCount = $state(PAGE_SIZE);
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
const nextPageMap = new Map<string, number>();
+3 -1
View File
@@ -428,7 +428,9 @@
</div>
{#if pickerOpen}
<div class="picker-backdrop" onclick={(e) => { if (e.target === e.currentTarget) closePicker(); }}>
<div class="picker-backdrop" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) closePicker(); }}
onkeydown={(e) => { if (e.key === "Escape") closePicker(); }}>
<div class="picker-modal">
<div class="picker-header">
<span class="picker-title">Pin manga — slot {(pickerSlotIndex ?? 0) + 1}</span>
+8 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
import { untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
import type { Manga, Source, Chapter } from "../../lib/types";
@@ -36,8 +37,7 @@
let sources: Source[] = $state([]);
let loadingSources = $state(true);
let selectedSource: Source | null = $state(null);
const _initialTitle = manga.title;
let query = $state(_initialTitle);
let query = $state(untrack(() => manga.title));
let results: { manga: Manga; similarity: number }[] = $state([]);
let searching = $state(false);
let selectedMatch: Match | null = $state(null);
@@ -220,9 +220,9 @@
<div class="search-row">
<div class="search-bar">
<MagnifyingGlass size={13} weight="light" class="search-icon" />
<input class="search-input" bind:value={query}
<input class="search-input" bind:value={query}
onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
placeholder="Search title…" autofocus />
placeholder="Search title…" use:focusOnMount />
</div>
<button class="search-btn"
onclick={() => selectedSource && searchSource(selectedSource, query)}
@@ -471,3 +471,7 @@
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
<script module>
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>
+5 -10
View File
@@ -509,7 +509,7 @@
<input
bind:this={kw_inputEl}
bind:value={kw_query}
autofocus
use:focusOnMount
class="searchInput"
placeholder="Search across sources…"
onkeydown={(e) => e.key === "Enter" && kwDoSearch(kw_query)}
@@ -1822,15 +1822,6 @@
/* ── Source tab: lang filter + browse bar ──────────────────────────────── */
.langFilterRow {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.sourceBrowseBar {
display: flex;
align-items: center;
@@ -1930,3 +1921,7 @@
}
.splitItemActive .langOptionDot { background: var(--accent-fg); }
</style>
<script module>
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>
+7 -3
View File
@@ -433,7 +433,7 @@
{#if folderCreating}
<div class="fp-create">
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} autofocus />
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} use:focusOnMount />
<button class="fp-confirm" onclick={createFolder} disabled={!folderNewName.trim()}>Add</button>
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
<X size={12} weight="light" />
@@ -452,7 +452,7 @@
<button class="jump-toggle" onclick={() => { jumpOpen = true; jumpInput = ""; }}>Go to…</button>
{:else}
<div class="jump-row">
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput} autofocus
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput} use:focusOnMount
onkeydown={(e) => {
if (e.key === "Escape") { jumpOpen = false; return; }
if (e.key === "Enter") {
@@ -501,7 +501,7 @@
{:else}
<div class="dl-range-row">
<button class="dl-range-back" onclick={() => showRange = false}></button>
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} autofocus />
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} use:focusOnMount />
<span class="dl-range-sep"></span>
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} onkeydown={(e) => e.key === "Enter" && enqueueRange()} />
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} onclick={enqueueRange}>Go</button>
@@ -752,3 +752,7 @@
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
<script module>
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>