Chore: Port over Settings (Barely Works)

This commit is contained in:
Youwes09
2026-05-24 20:31:46 -05:00
parent ae5d9748c7
commit d9a9427e3b
87 changed files with 8821 additions and 615 deletions
+24
View File
@@ -0,0 +1,24 @@
import { appState } from '$lib/state/app.svelte'
import type { Manga } from '$lib/types'
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
return new Promise(resolve => {
const worker = new Worker(new URL('./autoLinkWorker.ts', import.meta.url), { type: 'module' })
worker.onmessage = (e: MessageEvent<number[]>) => {
const matches = e.data
for (const id of matches) appState.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: appState.settings.mangaLinks?.[focal.id] ?? [],
})
})
}
+17 -20
View File
@@ -1,29 +1,26 @@
interface WorkerMsg {
focalTitle: string;
focalId: number;
allManga: { id: number; title: string }[];
linkedIds: number[];
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;
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<WorkerMsg>) => {
const { focalTitle, focalId, allManga, linkedIds } = e.data;
const matches: number[] = [];
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.65) matches.push(m.id);
if (m.id === focalId) continue
if (linkedIds.includes(m.id)) continue
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id)
}
self.postMessage(matches);
};
self.postMessage(matches)
}
+28 -29
View File
@@ -1,54 +1,53 @@
const THUMB_SIZE = 16;
const DUPE_THRESH = 0.12;
const hashCache = new Map<string, Uint8ClampedArray>();
const THUMB_SIZE = 16
const DUPE_THRESH = 0.12
const hashCache = new Map<string, Uint8ClampedArray>()
function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray {
const gray = new Uint8ClampedArray(pixels);
const gray = new Uint8ClampedArray(pixels)
for (let i = 0; i < pixels; i++) {
const o = i * 4;
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000;
const o = i * 4
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000
}
return gray;
return gray
}
function loadThumb(url: string): Promise<Uint8ClampedArray> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = THUMB_SIZE;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE);
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE));
};
img.onerror = reject;
img.src = url;
});
const canvas = document.createElement('canvas')
canvas.width = canvas.height = THUMB_SIZE
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE)
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE))
}
img.onerror = reject
img.src = url
})
}
function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number {
let diff = 0;
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
return diff / (a.length * 255);
let diff = 0
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i])
return diff / (a.length * 255)
}
export async function getHash(url: string): Promise<Uint8ClampedArray | null> {
if (hashCache.has(url)) return hashCache.get(url)!;
if (hashCache.has(url)) return hashCache.get(url)!
try {
const thumb = await loadThumb(url);
hashCache.set(url, thumb);
return thumb;
const thumb = await loadThumb(url)
hashCache.set(url, thumb)
return thumb
} catch {
return null;
return null
}
}
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
return similarity(a, b) <= DUPE_THRESH;
return similarity(a, b) <= DUPE_THRESH
}
export function clearHashCache(): void {
hashCache.clear();
hashCache.clear()
}
+92
View File
@@ -0,0 +1,92 @@
import { appState } from '$lib/state/app.svelte'
import { searchWithScore } from '$lib/core/algorithms/search'
import { getHash, areDuplicates } from '$lib/core/cover/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.65
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 appState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl
}
function fuzzyMatchIds(
mangaId: number,
title: string,
mangaById: Map<number, CoverManga & { title: string }>,
): number[] {
return searchWithScore(
[...mangaById.values()].filter(m => m.id !== mangaId),
title,
m => m.title,
)
.filter(r => r.score >= FUZZY_SCORE_THRESHOLD)
.map(r => r.item.id)
}
export function coverCandidatesSync(
mangaId: number,
title: string,
ownUrl: string,
mangaById: Map<number, CoverManga & { title: string }>,
): CoverCandidate[] {
const linkedIds = appState.getLinkedMangaIds(mangaId)
const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById)
const current = appState.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<string>()
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<CoverCandidate[]> {
const hashes = await Promise.all(candidates.map(c => getHash(c.url)))
const groups: number[][] = []
for (let i = 0; i < candidates.length; i++) {
const hi = hashes[i]
const existing = hi
? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false })
: undefined
if (existing) existing.push(i)
else groups.push([i])
}
return groups.map(group => {
const active = group.find(i => candidates[i].isActive) ?? group[0]
const labels = [...new Set(group.map(i => candidates[i].label))]
return { ...candidates[active], label: labels.join(' · ') }
})
}