diff --git a/src/features/library/components/LibraryGrid.svelte b/src/features/library/components/LibraryGrid.svelte index f7dc676..bd95603 100644 --- a/src/features/library/components/LibraryGrid.svelte +++ b/src/features/library/components/LibraryGrid.svelte @@ -1,6 +1,7 @@ @@ -71,7 +77,7 @@
- +
{#if loadingManga} @@ -157,6 +163,9 @@ 0 ? "fill" : "light"} /> Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""} + @@ -310,4 +319,4 @@ .detail-action-danger { color: var(--color-error); } .detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); } .detail-action-danger:disabled { opacity: 0.4; cursor: default; } - + \ No newline at end of file diff --git a/src/features/series/lib/autoLink.ts b/src/features/series/lib/autoLink.ts new file mode 100644 index 0000000..a256058 --- /dev/null +++ b/src/features/series/lib/autoLink.ts @@ -0,0 +1,27 @@ +import { store, linkManga } from "@store/state.svelte"; +import type { Manga } from "@types"; + +export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise { + return new Promise((resolve) => { + const worker = new Worker( + new URL("./autoLinkWorker.ts", import.meta.url), + { type: "module" }, + ); + + worker.onmessage = (e: MessageEvent) => { + const matches = e.data; + for (const id of matches) linkManga(focal.id, id); + worker.terminate(); + resolve(matches.length); + }; + + worker.onerror = () => { worker.terminate(); resolve(0); }; + + worker.postMessage({ + focalTitle: focal.title, + focalId: focal.id, + allManga: allManga.map(m => ({ id: m.id, title: m.title })), + linkedIds: store.settings.mangaLinks?.[focal.id] ?? [], + }); + }); +} \ No newline at end of file diff --git a/src/features/series/lib/autoLinkWorker.ts b/src/features/series/lib/autoLinkWorker.ts new file mode 100644 index 0000000..132fd6f --- /dev/null +++ b/src/features/series/lib/autoLinkWorker.ts @@ -0,0 +1,29 @@ +interface WorkerMsg { + focalTitle: string; + focalId: number; + allManga: { id: number; title: string }[]; + linkedIds: number[]; +} + +function titleSimilarity(a: string, b: string): number { + const norm = (s: string) => + s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean); + const wa = new Set(norm(a)); + const wb = new Set(norm(b)); + if (!wa.size || !wb.size) return 0; + const intersection = [...wa].filter(w => wb.has(w)).length; + return intersection / new Set([...wa, ...wb]).size; +} + +self.onmessage = (e: MessageEvent) => { + const { focalTitle, focalId, allManga, linkedIds } = e.data; + const matches: number[] = []; + + for (const m of allManga) { + if (m.id === focalId) continue; + if (linkedIds.includes(m.id)) continue; + if (titleSimilarity(focalTitle, m.title) >= 0.4) matches.push(m.id); + } + + self.postMessage(matches); +}; \ No newline at end of file diff --git a/src/features/series/lib/coverHash.ts b/src/features/series/lib/coverHash.ts new file mode 100644 index 0000000..d8a5ceb --- /dev/null +++ b/src/features/series/lib/coverHash.ts @@ -0,0 +1,89 @@ +import { getBlobUrl } from "@core/cache/imageCache"; + +const HASH_SIZE = 8; +const HASH_PIXELS = HASH_SIZE * HASH_SIZE; +const CANVAS_SIZE = 32; +const DUPE_THRESH = 10; + +const hashCache = new Map(); + +async function loadGrayscale(blobUrl: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = CANVAS_SIZE; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(img, 0, 0, CANVAS_SIZE, CANVAS_SIZE); + const { data } = ctx.getImageData(0, 0, CANVAS_SIZE, CANVAS_SIZE); + const gray = new Uint8ClampedArray(CANVAS_SIZE * CANVAS_SIZE); + for (let i = 0; i < gray.length; i++) { + const o = i * 4; + gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000; + } + resolve(gray); + }; + img.onerror = reject; + img.src = blobUrl; + }); +} + +function dct8x8(gray: Uint8ClampedArray): number[] { + const N = CANVAS_SIZE; + const step = N / HASH_SIZE; + const block: number[] = []; + + for (let by = 0; by < HASH_SIZE; by++) { + for (let bx = 0; bx < HASH_SIZE; bx++) { + let sum = 0, count = 0; + for (let dy = 0; dy < step; dy++) { + for (let dx = 0; dx < step; dx++) { + sum += gray[(by * step + dy) * N + (bx * step + dx)]; + count++; + } + } + block.push(sum / count); + } + } + return block; +} + +function pHash(block: number[]): Uint8Array { + const mean = block.reduce((a, b) => a + b, 0) / HASH_PIXELS; + const bits = new Uint8Array(Math.ceil(HASH_PIXELS / 8)); + for (let i = 0; i < HASH_PIXELS; i++) { + if (block[i] >= mean) bits[i >> 3] |= 1 << (i & 7); + } + return bits; +} + +function hammingDistance(a: Uint8Array, b: Uint8Array): number { + let dist = 0; + for (let i = 0; i < a.length; i++) { + let x = a[i] ^ b[i]; + while (x) { dist += x & 1; x >>= 1; } + } + return dist; +} + +export async function getHash(url: string, priority = -1): Promise { + if (hashCache.has(url)) return hashCache.get(url)!; + try { + const blob = await getBlobUrl(url, priority); + const gray = await loadGrayscale(blob); + const block = dct8x8(gray); + const hash = pHash(block); + hashCache.set(url, hash); + return hash; + } catch { + return null; + } +} + +export function areDuplicates(a: Uint8Array, b: Uint8Array): boolean { + return hammingDistance(a, b) <= DUPE_THRESH; +} + +export function clearHashCache(): void { + hashCache.clear(); +} \ No newline at end of file diff --git a/src/features/series/lib/coverResolver.ts b/src/features/series/lib/coverResolver.ts new file mode 100644 index 0000000..fc9e375 --- /dev/null +++ b/src/features/series/lib/coverResolver.ts @@ -0,0 +1,94 @@ +import { store } from "@store/state.svelte"; +import { searchWithScore } from "@core/algorithms/search"; +import { getHash, areDuplicates } from "@features/series/lib/coverHash"; + +type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null }; + +export type CoverCandidate = { + mangaId: number; + url: string; + label: string; + isActive: boolean; +}; + +const FUZZY_SCORE_THRESHOLD = 0.5; + +function normalizeUrl(url: string): string { + try { + const u = new URL(url); + u.search = ""; + return u.href.toLowerCase(); + } catch { + return url.toLowerCase(); + } +} + +export function resolvedCover(mangaId: number, ownUrl: string): string { + return store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl; +} + +function fuzzyMatchIds( + mangaId: number, + title: string, + mangaById: Map, +): number[] { + const results = searchWithScore( + [...mangaById.values()].filter(m => m.id !== mangaId), + title, + m => m.title, + ); + return results + .filter(r => r.score >= FUZZY_SCORE_THRESHOLD) + .map(r => r.item.id); +} + +export function coverCandidatesSync( + mangaId: number, + title: string, + ownUrl: string, + mangaById: Map, +): CoverCandidate[] { + const linkedIds = store.getLinkedMangaIds(mangaId); + const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById); + const current = store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl; + + const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds])); + + const raw: { mangaId: number; url: string; label: string }[] = [ + { mangaId, url: ownUrl, label: "This source" }, + ...allIds.flatMap(id => { + const m = mangaById.get(id); + return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : []; + }), + ]; + + const seen = new Set(); + return raw + .filter(c => { + const key = normalizeUrl(c.url); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) })); +} + +export async function dedupeByImage(candidates: CoverCandidate[]): Promise { + const hashes = await Promise.all(candidates.map(c => getHash(c.url))); + + const keptIndices: number[] = []; + + for (let i = 0; i < candidates.length; i++) { + const hi = hashes[i]; + if (!hi) { keptIndices.push(i); continue; } + + const isDupe = keptIndices.some(j => { + const hj = hashes[j]; + return hj ? areDuplicates(hi, hj) : false; + }); + + if (!isDupe) keptIndices.push(i); + } + + return keptIndices.map(i => candidates[i]); +} \ No newline at end of file diff --git a/src/features/series/panels/CoverPickerPanel.svelte b/src/features/series/panels/CoverPickerPanel.svelte new file mode 100644 index 0000000..6a68588 --- /dev/null +++ b/src/features/series/panels/CoverPickerPanel.svelte @@ -0,0 +1,215 @@ + + + + + \ No newline at end of file diff --git a/src/features/series/panels/SeriesLinkPanel.svelte b/src/features/series/panels/SeriesLinkPanel.svelte new file mode 100644 index 0000000..36ffb4d --- /dev/null +++ b/src/features/series/panels/SeriesLinkPanel.svelte @@ -0,0 +1,237 @@ + + + + + \ No newline at end of file diff --git a/src/features/settings/sections/LibrarySettings.svelte b/src/features/settings/sections/LibrarySettings.svelte index e22b175..f023b9a 100644 --- a/src/features/settings/sections/LibrarySettings.svelte +++ b/src/features/settings/sections/LibrarySettings.svelte @@ -63,7 +63,16 @@
-

History

+

Series

+
+ +
+
+ +
Reading history{store.history.length} entries
diff --git a/src/shared/manga/MangaPreview.svelte b/src/shared/manga/MangaPreview.svelte index a2a9efb..953eb7b 100644 --- a/src/shared/manga/MangaPreview.svelte +++ b/src/shared/manga/MangaPreview.svelte @@ -2,7 +2,7 @@ import { onMount, onDestroy } from "svelte"; import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, - Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak, + Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak, Image, } from "phosphor-svelte"; import { gql } from "@api/client"; import Thumbnail from "@shared/manga/Thumbnail.svelte"; @@ -10,10 +10,14 @@ import { FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations"; import { cache, CACHE_KEYS } from "@core/cache"; import { - store, openReader, addToast, linkManga, unlinkManga, + store, openReader, addToast, setPreviewManga, setActiveManga, setNavPage, setGenreFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, addBookmark, } from "@store/state.svelte"; + import { resolvedCover } from "@features/series/lib/coverResolver"; + import CoverPickerPanel from "@features/series/panels/CoverPickerPanel.svelte"; + import SeriesLinkPanel from "@features/series/panels/SeriesLinkPanel.svelte"; + import { autoLinkLibrary } from "@features/series/lib/autoLink"; import type { Manga, Chapter, Category } from "@types/index"; @@ -33,25 +37,18 @@ let fetchError: string | null = $state(null); let folderRef: HTMLDivElement = $state() as HTMLDivElement; - let linkPickerOpen = $state(false); - let linkSearch = $state(""); let allMangaForLink: Manga[] = $state([]); let loadingLinkList = $state(false); + let coverPickerOpen = $state(false); const linkedIds = $derived( store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : [], ); - const linkPickerResults = $derived.by(() => { - const others = allMangaForLink.filter((m) => m.id !== store.previewManga?.id); - const q = linkSearch.trim().toLowerCase(); - const filtered = q ? others.filter((m) => m.title.toLowerCase().includes(q)) : others; - const linked = filtered.filter((m) => linkedIds.includes(m.id)); - const rest = filtered.filter((m) => !linkedIds.includes(m.id)).slice(0, 30); - return [...linked, ...rest]; - }); - + const hasCoverOverride = $derived( + !!store.settings.mangaPrefs?.[store.previewManga?.id ?? -1]?.coverUrl + ); const displayManga = $derived(manga ?? store.previewManga); const totalCount = $derived(chapters.length); @@ -128,7 +125,21 @@ } async function openLinkPicker() { - linkPickerOpen = true; linkSearch = ""; + linkPickerOpen = true; + if (allMangaForLink.length) return; + loadingLinkList = true; + gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA) + .then((d) => { + allMangaForLink = d.mangas.nodes; + }) + .catch(console.error) + .finally(() => { loadingLinkList = false; }); + } + + function closeLinkPicker() { linkPickerOpen = false; } + + async function openCoverPicker() { + coverPickerOpen = true; if (allMangaForLink.length) return; loadingLinkList = true; gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA) @@ -137,18 +148,28 @@ .finally(() => { loadingLinkList = false; }); } - function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; } - - function handleLink(other: Manga) { - if (!store.previewManga) return; - if (linkedIds.includes(other.id)) unlinkManga(store.previewManga.id, other.id); - else linkManga(store.previewManga.id, other.id); - } - $effect(() => { - if (store.previewManga) { - load(store.previewManga.id); - loadCategories(store.previewManga.id); + const shouldAutoLink = store.settings.autoLinkOnOpen; + const focal = store.previewManga; + if (focal) { + load(focal.id); + loadCategories(focal.id); + if (shouldAutoLink) { + if (allMangaForLink.length) { + autoLinkLibrary(focal, allMangaForLink) + .then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); }); + } else { + loadingLinkList = true; + gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA) + .then((d) => { + allMangaForLink = d.mangas.nodes; + return autoLinkLibrary(focal, d.mangas.nodes); + }) + .then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); }) + .catch(console.error) + .finally(() => { loadingLinkList = false; }); + } + } } }); @@ -343,7 +364,7 @@
- + {#if loadingDetail}
@@ -435,6 +456,17 @@ {linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"} + +
@@ -632,53 +664,20 @@ {/if} -{#if linkPickerOpen} - +{#if linkPickerOpen && store.previewManga} + +{/if} + +{#if coverPickerOpen && store.previewManga} + { coverPickerOpen = false; }} + /> {/if}