Skip to content

Commit 3e7dbb9

Browse files
authored
Merge pull request Gnathonic#246 from Gnathonic/fix/fuzzy-file-matching
fix: Add fuzzy file matching for image cache
2 parents cd60d58 + 4352dd9 commit 3e7dbb9

5 files changed

Lines changed: 173 additions & 60 deletions

File tree

src/lib/components/Reader/Reader.svelte

Lines changed: 13 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -551,36 +551,19 @@
551551
let imageCache = new ImageCache();
552552
let cachedImageUrl1 = $state<string | null>(null);
553553
let cachedImageUrl2 = $state<string | null>(null);
554-
let filesArray = $state<File[]>([]);
555-
let lastVolumeUuid = $state<string>('');
556-
557-
// Update files array when volume changes (not on every volumeData update)
558-
$effect(() => {
559-
const files = volumeData?.files;
560-
const volumeUuid = volume?.volume_uuid;
561-
562-
// Only recreate filesArray when the volume UUID actually changes
563-
if (volumeUuid && volumeUuid !== lastVolumeUuid) {
564-
lastVolumeUuid = volumeUuid;
565-
if (files) {
566-
filesArray = Object.values(files);
567-
} else {
568-
filesArray = [];
569-
}
570-
} else if (files && filesArray.length === 0) {
571-
// Initial load: filesArray is empty but we have files
572-
filesArray = Object.values(files);
573-
}
574-
});
575554
576555
// Update cache when page or volume data changes
577556
$effect(() => {
578557
const currentIndex = index;
558+
const files = volumeData?.files;
559+
const pgs = pages;
579560
580-
if (filesArray.length > 0 && currentIndex >= 0) {
581-
// Try to get current page image synchronously first (instant if already cached)
582-
const syncUrl1 = imageCache.getImageSync(currentIndex);
561+
if (files && pgs.length > 0 && currentIndex >= 0) {
562+
// Update cache first (non-blocking - preloads in background)
563+
imageCache.updateCache(files, pgs, currentIndex);
583564
565+
// Try to get current page image synchronously (instant if already cached)
566+
const syncUrl1 = imageCache.getImageSync(currentIndex);
584567
if (syncUrl1) {
585568
cachedImageUrl1 = syncUrl1;
586569
} else {
@@ -591,11 +574,8 @@
591574
});
592575
}
593576
594-
// Update cache (non-blocking - preloads in background)
595-
imageCache.updateCache(filesArray, currentIndex);
596-
597577
// Try to get next page image if showing second page
598-
if (showSecondPage() && currentIndex + 1 < filesArray.length) {
578+
if (showSecondPage()) {
599579
const syncUrl2 = imageCache.getImageSync(currentIndex + 1);
600580
if (syncUrl2) {
601581
cachedImageUrl2 = syncUrl2;
@@ -802,10 +782,8 @@
802782
<QuickActions
803783
{left}
804784
{right}
805-
src1={volumeData.files ? Object.values(volumeData.files)[index] : undefined}
806-
src2={!useSinglePage && volumeData.files
807-
? Object.values(volumeData.files)[index + 1]
808-
: undefined}
785+
src1={imageCache.getFile(index)}
786+
src2={!useSinglePage ? imageCache.getFile(index + 1) : undefined}
809787
/>
810788
<SettingsButton />
811789
<Cropper />
@@ -904,18 +882,18 @@
904882
in:pageIn={{ direction: pageDirection }}
905883
out:pageOut={{ direction: pageDirection }}
906884
>
907-
{#if volumeData && volumeData.files}
885+
{#if volumeData?.files}
908886
{#if showSecondPage()}
909887
<MangaPage
910888
page={pages[index + 1]}
911-
src={Object.values(volumeData.files)[index + 1]}
889+
src={imageCache.getFile(index + 1)!}
912890
cachedUrl={cachedImageUrl2}
913891
volumeUuid={volume.volume_uuid}
914892
/>
915893
{/if}
916894
<MangaPage
917895
page={pages[index]}
918-
src={Object.values(volumeData.files)[index]}
896+
src={imageCache.getFile(index)!}
919897
cachedUrl={cachedImageUrl1}
920898
volumeUuid={volume.volume_uuid}
921899
/>

src/lib/reader/image-cache.ts

Lines changed: 136 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,192 @@
11
/**
22
* Image cache for preloading and decoding manga pages
33
* Maintains a windowed cache: previous 2 + current + next 3 pages
4+
*
5+
* Public API is index-based for clean caller usage.
6+
* Uses fuzzy matching to align files with pages when paths don't match exactly.
47
*/
58

9+
import type { Page } from '$lib/types';
10+
import { normalizeFilename } from '$lib/util/misc';
11+
612
export interface CachedImage {
713
image: HTMLImageElement; // Image element holds decoded bitmap and blob URL (in img.src)
814
decoded: boolean;
915
loading: Promise<void> | null;
1016
}
1117

18+
/**
19+
* Extract just the filename from a path (handles both / and \ separators)
20+
*/
21+
function getBasename(path: string): string {
22+
return path.split(/[/\\]/).pop() || path;
23+
}
24+
25+
/**
26+
* Natural sort comparator for filenames
27+
*/
28+
function naturalSort(a: string, b: string): number {
29+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
30+
}
31+
32+
/**
33+
* Match files to pages using fuzzy matching strategies
34+
* Returns an indexed array of Files aligned with page order
35+
*
36+
* Strategy order:
37+
* 1. Exact path match - all page.img_path values match file keys exactly
38+
* 2. Basename match - match just the filename portion without directories
39+
* 3. Page order fallback - sort files naturally and align by index
40+
*/
41+
function matchFilesToPages(files: Record<string, File>, pages: Page[]): File[] {
42+
const fileKeys = Object.keys(files);
43+
const result: File[] = new Array(pages.length);
44+
45+
// Build a normalized path -> original key mapping for lookups
46+
const normalizedToKey = new Map<string, string>();
47+
for (const key of fileKeys) {
48+
normalizedToKey.set(normalizeFilename(key), key);
49+
}
50+
51+
// Strategy 1: Try exact path matching (with normalization)
52+
let allExactMatches = true;
53+
for (let i = 0; i < pages.length; i++) {
54+
const imgPath = pages[i].img_path;
55+
const normalizedImgPath = normalizeFilename(imgPath);
56+
57+
// Try direct match first, then normalized match
58+
if (files[imgPath]) {
59+
result[i] = files[imgPath];
60+
} else if (normalizedToKey.has(normalizedImgPath)) {
61+
result[i] = files[normalizedToKey.get(normalizedImgPath)!];
62+
} else {
63+
allExactMatches = false;
64+
break;
65+
}
66+
}
67+
68+
if (allExactMatches) {
69+
return result;
70+
}
71+
72+
// Strategy 2: Try basename matching (with normalization)
73+
// Build a map of normalized basename -> file for all files
74+
const basenameToFile = new Map<string, File>();
75+
const basenameConflicts = new Set<string>();
76+
77+
for (const key of fileKeys) {
78+
const basename = normalizeFilename(getBasename(key));
79+
if (basenameToFile.has(basename)) {
80+
basenameConflicts.add(basename);
81+
} else {
82+
basenameToFile.set(basename, files[key]);
83+
}
84+
}
85+
86+
let allBasenameMatches = true;
87+
for (let i = 0; i < pages.length; i++) {
88+
const imgPath = pages[i].img_path;
89+
const basename = normalizeFilename(getBasename(imgPath));
90+
91+
if (basenameConflicts.has(basename)) {
92+
allBasenameMatches = false;
93+
break;
94+
}
95+
96+
const file = basenameToFile.get(basename);
97+
if (file) {
98+
result[i] = file;
99+
} else {
100+
allBasenameMatches = false;
101+
break;
102+
}
103+
}
104+
105+
if (allBasenameMatches) {
106+
return result;
107+
}
108+
109+
// Strategy 3: Fall back to page order (sort files naturally)
110+
const sortedKeys = fileKeys.sort(naturalSort);
111+
for (let i = 0; i < pages.length && i < sortedKeys.length; i++) {
112+
result[i] = files[sortedKeys[i]];
113+
}
114+
115+
return result;
116+
}
117+
12118
export class ImageCache {
13-
private cache = new Map<number, CachedImage>();
14-
private files: File[] = [];
119+
private cache = new Map<number, CachedImage>(); // Keyed by page index
120+
private files: File[] = []; // Indexed array aligned with pages
121+
private pages: Page[] = [];
15122
private currentIndex = 0;
16123
private windowSize = { prev: 2, next: 3 };
17124

18125
/**
19126
* Initialize or update the cache with new files and current page
20127
* Returns immediately - all preloading happens in the background
21128
*/
22-
updateCache(files: File[], currentIndex: number): void {
23-
const filesChanged = this.files !== files;
129+
updateCache(files: Record<string, File>, pages: Page[], currentIndex: number): void {
130+
// Detect if we have new files by checking reference and length
131+
const fileCount = Object.keys(files).length;
132+
const filesChanged = this.files.length !== fileCount || this.pages !== pages;
24133

25-
// Clear old cache if files changed
134+
// Clear old cache and build indexed files array if files changed
26135
if (filesChanged) {
27136
this.cleanup();
28-
this.files = files;
137+
this.files = matchFilesToPages(files, pages);
138+
this.pages = pages;
29139
}
30140

31141
this.currentIndex = currentIndex;
32142

33143
// Calculate window range
34144
const startIndex = Math.max(0, currentIndex - this.windowSize.prev);
35-
const endIndex = Math.min(files.length - 1, currentIndex + this.windowSize.next);
145+
const endIndex = Math.min(pages.length - 1, currentIndex + this.windowSize.next);
146+
147+
// Get indices in the window
148+
const windowIndices = new Set<number>();
149+
for (let i = startIndex; i <= endIndex; i++) {
150+
windowIndices.add(i);
151+
}
36152

37153
// Remove items outside the window
38-
for (const [index, cached] of this.cache.entries()) {
39-
if (index < startIndex || index > endIndex) {
154+
for (const [index] of this.cache.entries()) {
155+
if (!windowIndices.has(index)) {
40156
this.removeFromCache(index);
41157
}
42158
}
43159

44160
// Preload all items in the window (non-blocking)
45161
for (let i = startIndex; i <= endIndex; i++) {
46162
this.preloadImage(i).catch((err) => {
47-
console.error(`Failed to preload image ${i}:`, err);
163+
console.error(`Failed to preload image at index ${i}:`, err);
48164
});
49165
}
50166
}
51167

168+
/**
169+
* Get the File for a page index (for MangaPage fallback rendering)
170+
*/
171+
getFile(index: number): File | undefined {
172+
return this.files[index];
173+
}
174+
52175
/**
53176
* Get a cached image URL synchronously if it's ready, null otherwise
54177
*/
55178
getImageSync(index: number): string | null {
56-
if (index < 0 || index >= this.files.length) {
57-
return null;
58-
}
59-
60179
const cached = this.cache.get(index);
61180
if (cached && cached.decoded) {
62181
return cached.image.src;
63182
}
64-
65183
return null;
66184
}
67185

68186
/**
69187
* Get a cached image URL, waiting for it to be ready if necessary
70188
*/
71189
async getImage(index: number): Promise<string | null> {
72-
if (index < 0 || index >= this.files.length) {
73-
return null;
74-
}
75-
76190
const cached = this.cache.get(index);
77191
if (cached) {
78192
// Wait for image to be decoded if it's still loading
@@ -89,7 +203,7 @@ export class ImageCache {
89203
}
90204

91205
/**
92-
* Preload and decode an image at the given index
206+
* Preload and decode an image by its page index
93207
*/
94208
private async preloadImage(index: number): Promise<void> {
95209
// Already cached
@@ -187,11 +301,11 @@ export class ImageCache {
187301
return {
188302
size: this.cache.size,
189303
currentIndex: this.currentIndex,
190-
cached: Array.from(this.cache.keys()).sort((a, b) => a - b),
304+
fileCount: this.files.length,
305+
cached: Array.from(this.cache.keys()),
191306
decoded: Array.from(this.cache.entries())
192307
.filter(([_, v]) => v.decoded)
193308
.map(([k]) => k)
194-
.sort((a, b) => a - b)
195309
};
196310
}
197311
}

src/lib/upload/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { VolumeData, VolumeMetadata } from '$lib/types';
33
import { showSnackbar } from '$lib/util/snackbar';
44
import { promptImageOnlyImport, type SeriesImportInfo } from '$lib/util/modals';
55
import { requestPersistentStorage } from '$lib/util/upload';
6+
import { normalizeFilename } from '$lib/util/misc';
67
import { getMimeType, ZipReaderStream } from '@zip.js/zip.js';
78
import { generateThumbnail } from '$lib/catalog/thumbnails';
89
import { calculateCumulativeCharCounts } from '$lib/catalog/migration';
@@ -409,11 +410,12 @@ async function processStandaloneImage(
409410
volumesByPath: Record<string, Partial<VolumeMetadata>>,
410411
pendingImagesByPath: Record<string, Record<string, File>>
411412
): Promise<void> {
412-
const path = file.path;
413+
const path = normalizeFilename(file.path);
413414

414415
if (!path) return;
415416

416-
const relativePath = file.file.name;
417+
// Normalize filename to handle URL-encoded Unicode characters
418+
const relativePath = normalizeFilename(file.file.name);
417419
const vol = Object.keys(volumesDataByPath).find((key) => path.startsWith(key));
418420

419421
if (!vol) {

src/lib/util/download-queue.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
incrementPoolUsers,
2525
decrementPoolUsers
2626
} from './file-processing-pool';
27+
import { normalizeFilename } from './misc';
2728

2829
export interface QueueItem {
2930
volumeUuid: string;
@@ -341,6 +342,7 @@ async function processVolumeData(
341342

342343
// Convert image entries to File objects
343344
// Create File objects directly from ArrayBuffers with proper MIME types
345+
// Normalize filenames to decode URL-encoded Unicode characters
344346
const files: Record<string, File> = {};
345347
for (const entry of entries) {
346348
if (!entry.filename.endsWith('.mokuro') && !entry.filename.includes('__MACOSX')) {
@@ -356,8 +358,11 @@ async function processVolumeData(
356358
};
357359
const mimeType = mimeTypes[extension] || 'application/octet-stream';
358360

361+
// Normalize filename to handle URL-encoded Unicode characters
362+
const normalizedFilename = normalizeFilename(entry.filename);
363+
359364
// Create File directly from ArrayBuffer with proper MIME type
360-
files[entry.filename] = new File([entry.data], entry.filename, { type: mimeType });
365+
files[normalizedFilename] = new File([entry.data], normalizedFilename, { type: mimeType });
361366
}
362367
}
363368

src/lib/util/misc.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,17 @@ export function resetScrollPosition() {
7777
document.body.scrollTop = 0;
7878
}
7979
}
80+
81+
/**
82+
* Normalize a filename/path by decoding URL-encoded characters.
83+
* ZIP libraries may encode Unicode filenames (e.g., %E3%82%A2 -> ア).
84+
* This ensures consistent keys when storing/looking up files.
85+
*/
86+
export function normalizeFilename(filename: string): string {
87+
try {
88+
return decodeURIComponent(filename);
89+
} catch {
90+
// If decoding fails (invalid encoding), return as-is
91+
return filename;
92+
}
93+
}

0 commit comments

Comments
 (0)