fix(cbx): make cbx file detection more robust

This commit is contained in:
neon
2026-05-12 23:21:01 +00:00
parent e3d82b53e5
commit a345180a8b
9 changed files with 262 additions and 9 deletions
@@ -0,0 +1,83 @@
vi.mock('fs/promises', () => ({ open: vi.fn() }));
import { open } from 'fs/promises';
import { detectComicContainerFormat } from './comic-format-detect';
type MockFileHandle = { read: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
const mockOpen = open as unknown as ReturnType<typeof vi.fn>;
function makeHandle(bytes: number[]): MockFileHandle {
return {
read: vi.fn().mockImplementation((buf: Buffer, offset: number, length: number) => {
const src = Buffer.from(bytes);
const n = Math.min(length, src.length);
src.copy(buf, offset, 0, n);
return Promise.resolve({ bytesRead: n });
}),
close: vi.fn().mockResolvedValue(undefined),
};
}
describe('detectComicContainerFormat', () => {
beforeEach(() => vi.resetAllMocks());
it('returns "cb7" immediately without reading the file', async () => {
await expect(detectComicContainerFormat('/a.cb7', 'cb7')).resolves.toBe('cb7');
expect(mockOpen).not.toHaveBeenCalled();
});
it('detects a ZIP file as "cbz" regardless of stored format', async () => {
mockOpen.mockResolvedValue(makeHandle([0x50, 0x4b, 0x03, 0x04]));
await expect(detectComicContainerFormat('/a.cbr', 'cbr')).resolves.toBe('cbz');
});
it('detects a RAR file as "cbr" regardless of stored format', async () => {
mockOpen.mockResolvedValue(makeHandle([0x52, 0x61, 0x72, 0x21]));
await expect(detectComicContainerFormat('/a.cbz', 'cbz')).resolves.toBe('cbr');
});
it('returns stored format when bytes match neither ZIP nor RAR', async () => {
mockOpen.mockResolvedValue(makeHandle([0x00, 0x01, 0x02, 0x03]));
await expect(detectComicContainerFormat('/a.cbz', 'cbz')).resolves.toBe('cbz');
});
it('returns stored format when file is shorter than 4 bytes', async () => {
mockOpen.mockResolvedValue(makeHandle([0x50, 0x4b]));
await expect(detectComicContainerFormat('/tiny.cbr', 'cbr')).resolves.toBe('cbr');
});
it('returns stored format when file is shorter than 4 bytes', async () => {
mockOpen.mockResolvedValue(makeHandle([0x52]));
await expect(detectComicContainerFormat('/a.cbz', 'cbz')).resolves.toBe('cbz');
});
it('returns stored format on ENOENT (file not found)', async () => {
mockOpen.mockRejectedValue(Object.assign(new Error('ENOENT: no such file'), { code: 'ENOENT' }));
await expect(detectComicContainerFormat('/missing.cbz', 'cbz')).resolves.toBe('cbz');
});
it('returns stored format when open throws any other I/O error', async () => {
mockOpen.mockRejectedValue(new Error('EACCES: permission denied'));
await expect(detectComicContainerFormat('/locked.cbr', 'cbr')).resolves.toBe('cbr');
});
it('closes the file handle even when read throws', async () => {
const handle = { read: vi.fn().mockRejectedValue(new Error('read error')), close: vi.fn().mockResolvedValue(undefined) };
mockOpen.mockResolvedValue(handle);
await expect(detectComicContainerFormat('/bad.cbz', 'cbz')).resolves.toBe('cbz');
expect(handle.close).toHaveBeenCalled();
});
it('closes the file handle after a successful read', async () => {
const handle = makeHandle([0x50, 0x4b, 0x03, 0x04]);
mockOpen.mockResolvedValue(handle);
await detectComicContainerFormat('/good.cbz', 'cbz');
expect(handle.close).toHaveBeenCalled();
});
it('identifies ZIP even when storedFmt is cbz (no false override)', async () => {
mockOpen.mockResolvedValue(makeHandle([0x50, 0x4b, 0x03, 0x04]));
await expect(detectComicContainerFormat('/a.cbz', 'cbz')).resolves.toBe('cbz');
});
});
+40
View File
@@ -0,0 +1,40 @@
import { open } from 'fs/promises';
export type ComicContainerFormat = 'cbz' | 'cbr' | 'cb7';
/**
* Reads 4 magic bytes from the file to determine the actual container format.
*
* Some archives are mislabelled: a RAR archive saved as .cbz, or a ZIP saved
* as .cbr. Extension-based classification routes them to the wrong reader;
* magic bytes always tell the truth.
*
* Only overrides the stored format for cbz/cbr (ZIP vs RAR are the common
* swap). CB7 (7-zip) has no overlap with either and is passed through as-is.
*
* Falls back to storedFmt on any I/O error (file not found, permission denied,
* truncated file) so callers get predictable behaviour and produce a clear
* downstream error rather than an unexpected I/O exception.
*/
export async function detectComicContainerFormat(absolutePath: string, storedFmt: ComicContainerFormat): Promise<ComicContainerFormat> {
if (storedFmt === 'cb7') return 'cb7';
let fh: Awaited<ReturnType<typeof open>> | undefined;
try {
fh = await open(absolutePath, 'r');
const buf = Buffer.allocUnsafe(4);
const { bytesRead } = await fh.read(buf, 0, 4, 0);
if (bytesRead >= 4) {
// RAR: Rar!
if (buf[0] === 0x52 && buf[1] === 0x61 && buf[2] === 0x72 && buf[3] === 0x21) return 'cbr';
// ZIP: PK
if (buf[0] === 0x50 && buf[1] === 0x4b) return 'cbz';
}
} catch {
return storedFmt;
} finally {
await fh?.close();
}
return storedFmt;
}
@@ -9,6 +9,8 @@ import { parseFb2File } from '../metadata/lib/fb2-parser';
import { parseMobiFile } from '../metadata/lib/mobi-parser';
import { parsePdfFile, type PdfParsed, type PdfParseWarning } from '../metadata/lib/pdf-parser';
import { extractCover, generateThumbnail, imageExt } from '../metadata/lib/cover';
import { detectComicContainerFormat } from '../../common/comic-format-detect';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { BookDockRepository } from './book-dock.repository';
@Injectable()
@@ -44,7 +46,7 @@ export class BookDockMetadataService {
});
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[book_dock.extract_metadata] [fail] fileId=${fileId} format=${format} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - metadata extraction failed`,
);
@@ -118,7 +120,8 @@ export class BookDockMetadataService {
}
private async fromCbx(absolutePath: string, format: 'cbz' | 'cbr' | 'cb7'): Promise<BookDockMetadata> {
const extractor = format === 'cbz' ? extractCbzMetadata : format === 'cbr' ? extractCbrMetadata : extractCb7Metadata;
const actualFormat = await detectComicContainerFormat(absolutePath, format);
const extractor = actualFormat === 'cbz' ? extractCbzMetadata : actualFormat === 'cbr' ? extractCbrMetadata : extractCb7Metadata;
const parsed = await extractor(absolutePath);
if (!parsed) return {};
return {
@@ -3,6 +3,7 @@ import { extractCbrCover } from '../lib/cover-cbr';
import { extractCbzCover } from '../lib/cover-cbz';
import { extractCb7Metadata, extractCbrMetadata, extractCbzMetadata } from '../lib/cbz-metadata';
import { parseBookFilename } from '../lib/filename-parser';
import { detectComicContainerFormat } from '../../../common/comic-format-detect';
import type { FormatExtractor, ParsedBookData } from './format-extractor.interface';
type ComicFormat = 'cbz' | 'cbr' | 'cb7';
@@ -23,9 +24,10 @@ export class ComicFormatExtractor implements FormatExtractor {
constructor(private readonly format: ComicFormat) {}
async extract(absolutePath: string): Promise<ParsedBookData | null> {
const actualFormat = await detectComicContainerFormat(absolutePath, this.format);
const [comicMetadata, cover] = await Promise.all([
metadataExtractors[this.format](absolutePath),
coverExtractors[this.format](absolutePath).catch(() => null),
metadataExtractors[actualFormat](absolutePath),
coverExtractors[actualFormat](absolutePath).catch(() => null),
]);
const fb = parseBookFilename(absolutePath);
@@ -51,6 +51,10 @@ vi.mock('./audio.extractor', () => ({
extractAudioMetadata: vi.fn(),
}));
vi.mock('../../../common/comic-format-detect', () => ({
detectComicContainerFormat: vi.fn().mockImplementation((_path: string, fmt: string) => Promise.resolve(fmt)),
}));
import { extractEpubCover } from '../lib/cover-epub';
import { extractFb2Cover } from '../lib/cover-fb2';
import { extractCbrCover } from '../lib/cover-cbr';
@@ -68,6 +72,7 @@ import { EpubFormatExtractor } from './epub-format.extractor';
import { Fb2FormatExtractor } from './fb2-format.extractor';
import { MobiFormatExtractor } from './mobi-format.extractor';
import { PdfFormatExtractor } from './pdf-format.extractor';
import { detectComicContainerFormat } from '../../../common/comic-format-detect';
const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction<typeof extractEpubMetadata>;
const mockExtractEpubCover = extractEpubCover as MockedFunction<typeof extractEpubCover>;
@@ -82,6 +87,7 @@ const mockParseMobiFile = parseMobiFile as MockedFunction<typeof parseMobiFile>;
const mockExtractMobiCover = extractMobiCover as MockedFunction<typeof extractMobiCover>;
const mockParsePdfFile = parsePdfFile as MockedFunction<typeof parsePdfFile>;
const mockExtractAudioMetadata = extractAudioMetadata as MockedFunction<typeof extractAudioMetadata>;
const mockDetectComicContainerFormat = detectComicContainerFormat as MockedFunction<typeof detectComicContainerFormat>;
describe('metadata format extractors', () => {
beforeEach(() => {
@@ -236,6 +242,30 @@ describe('metadata format extractors', () => {
);
});
it('comic extractor uses CBR reader when a CBZ file is actually a RAR archive', async () => {
mockDetectComicContainerFormat.mockResolvedValue('cbr');
mockExtractCbrMetadata.mockResolvedValue({
title: 'Mislabelled Issue',
description: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
authors: [],
genres: [],
tags: [],
} as any);
mockExtractCbrCover.mockResolvedValue(Buffer.from('cover'));
const result = await new ComicFormatExtractor('cbz').extract('/books/mislabelled.cbz');
expect(mockDetectComicContainerFormat).toHaveBeenCalledWith('/books/mislabelled.cbz', 'cbz');
expect(mockExtractCbrMetadata).toHaveBeenCalledWith('/books/mislabelled.cbz');
expect(mockExtractCbzMetadata).not.toHaveBeenCalled();
expect(result).toEqual(expect.objectContaining({ title: 'Mislabelled Issue' }));
});
it('mobi extractor parses year from publishedDate and tolerates missing cover', async () => {
mockParseMobiFile.mockResolvedValue({
title: 'Snow Crash',
@@ -5,6 +5,9 @@ vi.mock('./cover-cb7', () => ({ extractCb7Cover: vi.fn() }));
vi.mock('./cover-fb2', () => ({ extractFb2Cover: vi.fn() }));
vi.mock('./mobi-parser', () => ({ extractMobiCover: vi.fn() }));
vi.mock('./pdf-cover', () => ({ extractPdfCover: vi.fn() }));
vi.mock('../../../common/comic-format-detect', () => ({
detectComicContainerFormat: vi.fn().mockImplementation((_: string, fmt: string) => Promise.resolve(fmt)),
}));
vi.mock('sharp', () => {
const chain = {
resize: vi.fn(),
@@ -27,6 +30,7 @@ import { extractFb2Cover } from './cover-fb2';
import { extractMobiCover } from './mobi-parser';
import { extractPdfCover } from './pdf-cover';
import { coverDirPath, extractCover, generateThumbnail, imageExt } from './cover';
import { detectComicContainerFormat } from '../../../common/comic-format-detect';
const mockEpub = extractEpubCover as MockedFunction<typeof extractEpubCover>;
const mockMobi = extractMobiCover as MockedFunction<typeof extractMobiCover>;
const mockCbz = extractCbzCover as MockedFunction<typeof extractCbzCover>;
@@ -34,6 +38,7 @@ const mockCbr = extractCbrCover as MockedFunction<typeof extractCbrCover>;
const mockCb7 = extractCb7Cover as MockedFunction<typeof extractCb7Cover>;
const mockFb2 = extractFb2Cover as MockedFunction<typeof extractFb2Cover>;
const mockPdf = extractPdfCover as MockedFunction<typeof extractPdfCover>;
const mockDetect = detectComicContainerFormat as MockedFunction<typeof detectComicContainerFormat>;
const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0]);
@@ -47,6 +52,8 @@ beforeEach(() => {
mockCb7.mockResolvedValue(null);
mockFb2.mockResolvedValue(null);
mockPdf.mockResolvedValue(null);
// Default: detection confirms the stored format (no mislabelling)
mockDetect.mockImplementation((_path, fmt) => Promise.resolve(fmt));
});
// ── imageExt ──────────────────────────────────────────────────────────────────
@@ -181,4 +188,24 @@ describe('extractCover', () => {
await extractCover('/book.epub', 'EPUB');
expect(mockEpub).toHaveBeenCalled();
});
it('routes cbz-stored-as-rar to extractCbrCover', async () => {
mockDetect.mockResolvedValue('cbr');
mockCbr.mockResolvedValue(JPEG_BYTES);
const result = await extractCover('/mislabelled.cbz', 'cbz');
expect(mockDetect).toHaveBeenCalledWith('/mislabelled.cbz', 'cbz');
expect(mockCbr).toHaveBeenCalledWith('/mislabelled.cbz');
expect(mockCbz).not.toHaveBeenCalled();
expect(result).toBe(JPEG_BYTES);
});
it('routes cbr-stored-as-zip to extractCbzCover', async () => {
mockDetect.mockResolvedValue('cbz');
mockCbz.mockResolvedValue(JPEG_BYTES);
const result = await extractCover('/mislabelled.cbr', 'cbr');
expect(mockDetect).toHaveBeenCalledWith('/mislabelled.cbr', 'cbr');
expect(mockCbz).toHaveBeenCalledWith('/mislabelled.cbr');
expect(mockCbr).not.toHaveBeenCalled();
expect(result).toBe(JPEG_BYTES);
});
});
+5 -3
View File
@@ -1,6 +1,7 @@
import sharp from 'sharp';
import { bookCoverDirPath } from '../../../common/book-cover-storage';
import { detectComicContainerFormat } from '../../../common/comic-format-detect';
import { extractCb7Cover } from './cover-cb7';
import { extractCbrCover } from './cover-cbr';
import { extractCbzCover } from './cover-cbz';
@@ -51,9 +52,10 @@ export async function extractCover(absolutePath: string, format: string): Promis
case 'azw':
return extractMobiCover(absolutePath);
case 'cbz':
return extractCbzCover(absolutePath);
case 'cbr':
return extractCbrCover(absolutePath);
case 'cbr': {
const actual = await detectComicContainerFormat(absolutePath, format.toLowerCase() as 'cbz' | 'cbr');
return actual === 'cbr' ? extractCbrCover(absolutePath) : extractCbzCover(absolutePath);
}
case 'cb7':
return extractCb7Cover(absolutePath);
case 'fb2':
@@ -6,6 +6,9 @@ vi.mock('zlib', async () => {
});
vi.mock('node-unrar-js', () => ({ createExtractorFromData: vi.fn() }));
vi.mock('../../../common/sevenzip', () => ({ getSevenZip: vi.fn() }));
vi.mock('../../../common/comic-format-detect', () => ({
detectComicContainerFormat: vi.fn().mockImplementation((_path: string, fmt: string) => Promise.resolve(fmt)),
}));
import { NotFoundException } from '@nestjs/common';
import { createReadStream } from 'fs';
@@ -14,6 +17,7 @@ import { createExtractorFromData } from 'node-unrar-js';
import { createInflateRaw, deflateRawSync } from 'zlib';
import { getSevenZip } from '../../../common/sevenzip';
import { detectComicContainerFormat } from '../../../common/comic-format-detect';
import { CbzService } from './cbz.service';
const mockReadFile = readFile as MockedFunction<typeof readFile>;
@@ -21,6 +25,7 @@ const mockCreateReadStream = createReadStream as unknown as MockedFunction<typeo
const mockCreateInflateRaw = createInflateRaw as unknown as MockedFunction<typeof createInflateRaw>;
const mockCreateExtractorFromData = createExtractorFromData as MockedFunction<typeof createExtractorFromData>;
const mockGetSevenZip = getSevenZip as MockedFunction<typeof getSevenZip>;
const mockDetectComicContainerFormat = detectComicContainerFormat as MockedFunction<typeof detectComicContainerFormat>;
interface ZipEntrySpec {
name: string;
@@ -80,6 +85,7 @@ describe('CbzService', () => {
beforeEach(() => {
vi.resetAllMocks();
service = new CbzService(bookService as any);
mockDetectComicContainerFormat.mockImplementation((_path, fmt) => Promise.resolve(fmt));
});
it('counts visible CBZ images and caches the parsed index', async () => {
@@ -257,4 +263,48 @@ describe('CbzService', () => {
await expect(service.streamPage(9, 0, user)).rejects.toThrow(NotFoundException);
await expect(service.streamPage(9, 1, user)).rejects.toThrow(NotFoundException);
});
it('reads a CBZ-stored-as-RAR using the CBR reader when magic bytes detect RAR', async () => {
mockDetectComicContainerFormat.mockResolvedValue('cbr');
const rarBytes = Buffer.from('fake-rar');
bookService.verifyFileAccess.mockResolvedValue({ format: 'cbz', absolutePath: '/books/mislabelled.cbz' });
mockReadFile.mockResolvedValue(rarBytes as unknown as Awaited<ReturnType<typeof readFile>>);
mockCreateExtractorFromData
.mockResolvedValueOnce({
getFileList: () => ({ fileHeaders: [{ name: '1.jpg', flags: { directory: false } }] }),
} as any)
.mockResolvedValueOnce({
extract: () => ({ files: [{ fileHeader: { flags: { directory: false } }, extraction: Uint8Array.from([1, 2, 3]) }] }),
} as any);
await expect(service.getPageCount(10, user)).resolves.toBe(1);
const result = await service.streamPage(10, 0, user);
await expect(readStream(result.stream)).resolves.toEqual(Buffer.from([1, 2, 3]));
expect(mockDetectComicContainerFormat).toHaveBeenCalledWith('/books/mislabelled.cbz', 'cbz');
});
it('reads a CBR-stored-as-ZIP using the CBZ reader when magic bytes detect ZIP', async () => {
mockDetectComicContainerFormat.mockResolvedValue('cbz');
const rawStream = { kind: 'raw-stream' };
bookService.verifyFileAccess.mockResolvedValue({ format: 'cbr', absolutePath: '/books/mislabelled.cbr' });
mockReadFile.mockResolvedValue(
buildCbzBuffer([{ name: 'cover.jpg', data: Buffer.from([0xff, 0xd8]) }]) as unknown as Awaited<ReturnType<typeof readFile>>,
);
mockCreateReadStream.mockReturnValue(rawStream as unknown as ReturnType<typeof createReadStream>);
await expect(service.getPageCount(11, user)).resolves.toBe(1);
const result = await service.streamPage(11, 0, user);
expect(result.stream).toBe(rawStream);
expect(mockDetectComicContainerFormat).toHaveBeenCalledWith('/books/mislabelled.cbr', 'cbr');
});
it('caches resolved format and calls detectComicContainerFormat only once per fileId', async () => {
bookService.verifyFileAccess.mockResolvedValue({ format: 'cbz', absolutePath: '/books/cached.cbz' });
mockReadFile.mockResolvedValue(buildCbzBuffer([{ name: '1.jpg', data: Buffer.from([1]) }]) as unknown as Awaited<ReturnType<typeof readFile>>);
await service.getPageCount(12, user);
await service.getPageCount(12, user);
expect(mockDetectComicContainerFormat).toHaveBeenCalledTimes(1);
});
});
+18 -2
View File
@@ -6,6 +6,7 @@ import { createInflateRaw } from 'zlib';
import { createExtractorFromData, UnrarError } from 'node-unrar-js';
import { getSevenZip } from '../../../common/sevenzip';
import { imageContentTypeFromPath } from '../../../common/image-content-type';
import { detectComicContainerFormat } from '../../../common/comic-format-detect';
import type { RequestUser } from '../../../common/types/request-user';
import { BookService } from '../../book/book.service';
@@ -124,6 +125,8 @@ export class CbzService {
private readonly rarCache = new Map<number, RarCache>();
// CB7: sorted page names per fileId (extracted files live in WASM VFS)
private readonly sevenZPages = new Map<number, string[]>();
// Resolved actual format per fileId (magic bytes override stored extension)
private readonly resolvedFormat = new Map<number, string>();
constructor(private readonly bookService: BookService) {}
@@ -131,6 +134,19 @@ export class CbzService {
return this.bookService.verifyFileAccess(fileId, user);
}
private async resolveFormat(fileId: number, absolutePath: string, storedFmt: string | null): Promise<string> {
if (!this.resolvedFormat.has(fileId)) {
let resolved: string;
if (storedFmt === 'cbz' || storedFmt === 'cbr' || storedFmt === 'cb7') {
resolved = await detectComicContainerFormat(absolutePath, storedFmt);
} else {
resolved = storedFmt ?? '';
}
this.resolvedFormat.set(fileId, resolved);
}
return this.resolvedFormat.get(fileId)!;
}
// ── CBZ ──────────────────────────────────────────────────────────────────────
private async getCbzIndex(fileId: number, filePath: string): Promise<ZipEntry[]> {
@@ -230,7 +246,7 @@ export class CbzService {
async getPageCount(fileId: number, user: RequestUser): Promise<number> {
const file = await this.getFile(fileId, user);
const fmt = file.format ?? '';
const fmt = await this.resolveFormat(fileId, file.absolutePath, file.format);
if (fmt === 'cbz') return (await this.getCbzIndex(fileId, file.absolutePath)).length;
if (fmt === 'cbr') return (await this.getRarCache(fileId, file.absolutePath)).pages.length;
@@ -241,7 +257,7 @@ export class CbzService {
async streamPage(fileId: number, pageIndex: number, user: RequestUser): Promise<{ stream: NodeJS.ReadableStream; mimeType: string }> {
const file = await this.getFile(fileId, user);
const fmt = file.format ?? '';
const fmt = await this.resolveFormat(fileId, file.absolutePath, file.format);
if (fmt === 'cbz') {
const entries = await this.getCbzIndex(fileId, file.absolutePath);