fix(reader): sort comic names before their extensions

Compare extensionless archive entry paths first so a cover named IMG.jpg precedes page names that extend IMG with a suffix. Keep cover extraction and CBZ/CBR page delivery aligned, and invalidate old page ETags.

Closes #2035
This commit is contained in:
neon
2026-11-02 01:54:30 +00:00
parent 82608dbafd
commit 9b853935bb
7 changed files with 92 additions and 10 deletions
+16
View File
@@ -0,0 +1,16 @@
const ARCHIVE_ENTRY_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
function extensionIndex(path: string): number {
const separator = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
const dot = path.lastIndexOf('.');
return dot > separator + 1 ? dot : path.length;
}
export function compareArchiveEntryNames(a: string, b: string): number {
const aExtension = extensionIndex(a);
const bExtension = extensionIndex(b);
return (
ARCHIVE_ENTRY_COLLATOR.compare(a.slice(0, aExtension), b.slice(0, bExtension)) ||
ARCHIVE_ENTRY_COLLATOR.compare(a.slice(aExtension), b.slice(bExtension))
);
}
@@ -21,4 +21,9 @@ describe('archive-image-utils', () => {
const entries = ['page10.jpg', 'Page1.JPG', 'page2.jpg']; const entries = ['page10.jpg', 'Page1.JPG', 'page2.jpg'];
expect(entries.toSorted(compareArchiveEntryNames)).toEqual(['Page1.JPG', 'page2.jpg', 'page10.jpg']); expect(entries.toSorted(compareArchiveEntryNames)).toEqual(['Page1.JPG', 'page2.jpg', 'page10.jpg']);
}); });
it('sorts a complete base filename before a filename that extends its stem', () => {
const entries = ['IMG_0004_Seite_001_1L.jpg', 'IMG.jpg'];
expect(entries.toSorted(compareArchiveEntryNames)).toEqual(['IMG.jpg', 'IMG_0004_Seite_001_1L.jpg']);
});
}); });
@@ -1,8 +1,6 @@
const ARCHIVE_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']); const ARCHIVE_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']);
export function compareArchiveEntryNames(a: string, b: string): number { export { compareArchiveEntryNames } from '../../../common/archive-entry-sort';
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
}
export function isArchiveImageFile(name: string): boolean { export function isArchiveImageFile(name: string): boolean {
const dot = name.lastIndexOf('.'); const dot = name.lastIndexOf('.');
@@ -33,6 +33,23 @@ describe('extractCbrCover', () => {
await expect(extractCbrCover('/book.cbr')).resolves.toEqual(Buffer.from([7, 7, 7])); await expect(extractCbrCover('/book.cbr')).resolves.toEqual(Buffer.from([7, 7, 7]));
}); });
it('chooses a complete base filename before a filename that extends its stem', async () => {
const extract = vi.fn(() => ({ files: [{ fileHeader: { flags: { directory: false } }, extraction: Uint8Array.from([1]) }] }));
mockCreateExtractor
.mockResolvedValueOnce({
getFileList: () => ({
fileHeaders: [
{ name: 'IMG_0004_Seite_001_1L.jpg', flags: { directory: false } },
{ name: 'IMG.jpg', flags: { directory: false } },
],
}),
} as any)
.mockResolvedValueOnce({ extract } as any);
await expect(extractCbrCover('/book.cbr')).resolves.toEqual(Buffer.from([1]));
expect(extract).toHaveBeenCalledWith({ files: ['IMG.jpg'] });
});
it('returns null when archive has no visible image files', async () => { it('returns null when archive has no visible image files', async () => {
mockCreateExtractor.mockResolvedValue({ mockCreateExtractor.mockResolvedValue({
getFileList: () => ({ fileHeaders: [{ name: 'notes.txt', flags: { directory: false } }] }), getFileList: () => ({ fileHeaders: [{ name: 'notes.txt', flags: { directory: false } }] }),
@@ -133,6 +133,15 @@ describe('extractCbzCover', () => {
await expect(extractCbzCover('/book.cbz')).resolves.toEqual(Buffer.from([0x02])); await expect(extractCbzCover('/book.cbz')).resolves.toEqual(Buffer.from([0x02]));
}); });
it('chooses a complete base filename before a filename that extends its stem', async () => {
mockZip([
{ name: 'IMG_0004_Seite_001_1L.jpg', data: Buffer.from([0x04]) },
{ name: 'IMG.jpg', data: Buffer.from([0x01]) },
]);
await expect(extractCbzCover('/book.cbz')).resolves.toEqual(Buffer.from([0x01]));
});
it('skips hidden image files and returns first visible image', async () => { it('skips hidden image files and returns first visible image', async () => {
mockZip([ mockZip([
{ name: '.hidden.jpg', data: Buffer.from([0xaa]) }, { name: '.hidden.jpg', data: Buffer.from([0xaa]) },
@@ -198,6 +198,24 @@ describe('CbzService', () => {
expect(result.mimeType).toBe('image/jpeg'); expect(result.mimeType).toBe('image/jpeg');
}); });
it('streams a complete base filename before a filename that extends its stem', async () => {
const rawStream = { kind: 'raw-stream' };
bookService.verifyFileAccess.mockResolvedValue({ format: 'cbz', absolutePath: '/books/prefix.cbz' });
mockCbzFile(
buildCbzBuffer([
{ name: 'IMG_0004_Seite_001_1L.jpg', data: Buffer.from([4]), compression: 8 },
{ name: 'IMG.jpg', data: Buffer.from([1]), compression: 0 },
]),
);
mockCreateReadStream.mockReturnValue(rawStream as unknown as ReturnType<typeof createReadStream>);
const result = served(await service.streamPage(24, 0, user));
expect(mockCreateInflateRaw).not.toHaveBeenCalled();
expect(result.stream).toBe(rawStream);
});
it('streams STORED CBZ entries without inflate', async () => { it('streams STORED CBZ entries without inflate', async () => {
const rawStream = { kind: 'raw-stream' }; const rawStream = { kind: 'raw-stream' };
@@ -261,6 +279,28 @@ describe('CbzService', () => {
expect(mockReadFile).toHaveBeenCalledTimes(1); expect(mockReadFile).toHaveBeenCalledTimes(1);
}); });
it('streams a complete CBR base filename before a filename that extends its stem', async () => {
const extract = vi.fn(() => ({
files: [{ fileHeader: { flags: { directory: false } }, extraction: Uint8Array.from([1]) }],
}));
bookService.verifyFileAccess.mockResolvedValue({ format: 'cbr', absolutePath: '/books/prefix.cbr' });
mockReadFile.mockResolvedValue(Buffer.from('rar-bytes') as unknown as Awaited<ReturnType<typeof readFile>>);
mockCreateExtractorFromData
.mockResolvedValueOnce({
getFileList: () => ({
fileHeaders: [
{ name: 'IMG_0004_Seite_001_1L.jpg', flags: { directory: false } },
{ name: 'IMG.jpg', flags: { directory: false } },
],
}),
} as any)
.mockResolvedValueOnce({ extract } as any);
await service.streamPage(25, 0, user);
expect(extract).toHaveBeenCalledWith({ files: ['IMG.jpg'] });
});
it('throws when CBR extraction does not produce the requested file', async () => { it('throws when CBR extraction does not produce the requested file', async () => {
bookService.verifyFileAccess.mockResolvedValue({ format: 'cbr', absolutePath: '/books/seven.cbr' }); bookService.verifyFileAccess.mockResolvedValue({ format: 'cbr', absolutePath: '/books/seven.cbr' });
mockReadFile.mockResolvedValue(Buffer.from('rar') as unknown as Awaited<ReturnType<typeof readFile>>); mockReadFile.mockResolvedValue(Buffer.from('rar') as unknown as Awaited<ReturnType<typeof readFile>>);
+4 -7
View File
@@ -7,6 +7,7 @@ import { createSevenZipTempId, getSevenZip } from '../../../common/sevenzip';
import { imageContentTypeFromPath } from '../../../common/image-content-type'; import { imageContentTypeFromPath } from '../../../common/image-content-type';
import { detectComicContainerFormat } from '../../../common/comic-format-detect'; import { detectComicContainerFormat } from '../../../common/comic-format-detect';
import { createCbzZipEntryReadStream, readCbzZipIndex, type CbzZipEntry } from '../../../common/cbz-zip-reader'; import { createCbzZipEntryReadStream, readCbzZipIndex, type CbzZipEntry } from '../../../common/cbz-zip-reader';
import { compareArchiveEntryNames } from '../../../common/archive-entry-sort';
import { compareComicPagePaths, listExtractedFiles, pathBasename, removeExtractedTree } from './cb7-extraction'; import { compareComicPagePaths, listExtractedFiles, pathBasename, removeExtractedTree } from './cb7-extraction';
import type { RequestUser } from '../../../common/types/request-user'; import type { RequestUser } from '../../../common/types/request-user';
@@ -25,10 +26,6 @@ function isHidden(name: string): boolean {
return name.split('/').some((p) => p.startsWith('.')); return name.split('/').some((p) => p.startsWith('.'));
} }
function naturalSort(a: string, b: string): number {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
}
function mimeForExt(name: string): string { function mimeForExt(name: string): string {
return imageContentTypeFromPath(name); return imageContentTypeFromPath(name);
} }
@@ -58,7 +55,7 @@ function toArrayBuffer(buf: Buffer): ArrayBuffer {
// ── Freshness ────────────────────────────────────────────────────────────────── // ── Freshness ──────────────────────────────────────────────────────────────────
/** Bump when page listing or ordering changes, so a revalidation cannot keep a page from the old order. */ /** Bump when page listing or ordering changes, so a revalidation cannot keep a page from the old order. */
const PAGE_ETAG_VERSION = 1; const PAGE_ETAG_VERSION = 2;
type ComicFormat = 'cbz' | 'cbr' | 'cb7'; type ComicFormat = 'cbz' | 'cbr' | 'cb7';
@@ -189,7 +186,7 @@ export class CbzService {
const entries = index.entries const entries = index.entries
.filter((entry) => !entry.name.endsWith('/') && !isHidden(entry.name) && isImage(entry.name)) .filter((entry) => !entry.name.endsWith('/') && !isHidden(entry.name) && isImage(entry.name))
.filter((entry) => (entry.compression === 0 || entry.compression === 8) && entry.compressedSize > 0) .filter((entry) => (entry.compression === 0 || entry.compression === 8) && entry.compressedSize > 0)
.sort((a, b) => naturalSort(a.name, b.name)); .sort((a, b) => compareArchiveEntryNames(a.name, b.name));
this.zipIndex.set(fileId, { stamp, value: entries }); this.zipIndex.set(fileId, { stamp, value: entries });
return entries; return entries;
} }
@@ -221,7 +218,7 @@ export class CbzService {
} }
} }
pages.sort(naturalSort); pages.sort(compareArchiveEntryNames);
const value = { buffer: ab, pages }; const value = { buffer: ab, pages };
this.rarCache.set(fileId, { stamp, value }); this.rarCache.set(fileId, { stamp, value });
return value; return value;