mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(cbx): make cbx file detection more robust
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user