fix(metadata): support CBZ archive comments

Retry central-directory discovery at the ZIP comment limit when the default scan reaches the end of the file. Preserve the archive comment while rewriting ComicInfo.xml.

Fixes #1272
This commit is contained in:
neon
2026-09-05 09:49:57 +00:00
parent e63c4b3685
commit 43bf1b7cfa
3 changed files with 52 additions and 8 deletions
@@ -4,25 +4,42 @@ import * as unzipper from 'unzipper';
import { replaceFileAtomically } from '../shared/atomic-file-replace'; import { replaceFileAtomically } from '../shared/atomic-file-replace';
import { writeZipArchive, type ZipRewriteEntry } from '../shared/zip-rewrite'; import { writeZipArchive, type ZipRewriteEntry } from '../shared/zip-rewrite';
const ZIP_END_OF_CENTRAL_DIRECTORY_SIZE_BYTES = 22;
const MAX_ZIP_COMMENT_SIZE_BYTES = 0xffff;
const ZIP_TAIL_SIZE_BYTES = ZIP_END_OF_CENTRAL_DIRECTORY_SIZE_BYTES + MAX_ZIP_COMMENT_SIZE_BYTES;
type ZipCentralDirectory = unzipper.CentralDirectory & { comment?: string };
const openZipFileWithOptions = unzipper.Open.file as (filePath: string, options: { tailSize: number }) => Promise<ZipCentralDirectory>;
async function openZipFile(filePath: string): Promise<ZipCentralDirectory> {
try {
return await unzipper.Open.file(filePath);
} catch (error) {
if (!(error instanceof Error) || error.message !== 'FILE_ENDED') throw error;
return openZipFileWithOptions(filePath, { tailSize: ZIP_TAIL_SIZE_BYTES });
}
}
function isComicInfoEntry(entryPath: string): boolean { function isComicInfoEntry(entryPath: string): boolean {
const normalized = entryPath.replace(/\\/g, '/').toLowerCase(); const normalized = entryPath.replace(/\\/g, '/').toLowerCase();
return normalized === 'comicinfo.xml' || normalized.endsWith('/comicinfo.xml'); return normalized === 'comicinfo.xml' || normalized.endsWith('/comicinfo.xml');
} }
export async function readComicInfoFromZip(filePath: string): Promise<string | null> { export async function readComicInfoFromZip(filePath: string): Promise<string | null> {
const zip = await unzipper.Open.file(filePath); const zip = await openZipFile(filePath);
const entry = zip.files.find((f) => isComicInfoEntry(f.path)); const entry = zip.files.find((f) => isComicInfoEntry(f.path));
if (!entry) return null; if (!entry) return null;
return (await entry.buffer()).toString('utf-8'); return (await entry.buffer()).toString('utf-8');
} }
export async function writeComicInfoToZip(filePath: string, xmlContent: string): Promise<void> { export async function writeComicInfoToZip(filePath: string, xmlContent: string): Promise<void> {
const zip = await unzipper.Open.file(filePath); const zip = await openZipFile(filePath);
const existing = zip.files.find((f) => isComicInfoEntry(f.path)); const existing = zip.files.find((f) => isComicInfoEntry(f.path));
const xmlEntryPath = existing?.path ?? 'ComicInfo.xml'; const xmlEntryPath = existing?.path ?? 'ComicInfo.xml';
const tmpPath = join(dirname(filePath), `.cbx-write-${randomUUID()}`); const tmpPath = join(dirname(filePath), `.cbx-write-${randomUUID()}`);
await writeZipArchive(tmpPath, rewriteEntries(zip.files, xmlEntryPath, xmlContent)); await writeZipArchive(tmpPath, rewriteEntries(zip.files, xmlEntryPath, xmlContent), { comment: zip.comment });
await replaceFileAtomically(tmpPath, filePath); await replaceFileAtomically(tmpPath, filePath);
} }
@@ -1,6 +1,6 @@
import { ZipArchive } from 'archiver'; import { ZipArchive } from 'archiver';
import { createWriteStream } from 'fs'; import { createWriteStream } from 'fs';
import { mkdtemp, readdir, rm } from 'fs/promises'; import { mkdtemp, readFile, readdir, rm } from 'fs/promises';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import * as unzipper from 'unzipper'; import * as unzipper from 'unzipper';
@@ -9,6 +9,9 @@ import { readComicInfoFromZip, writeComicInfoToZip } from './cbz-zip-patcher';
let testRoot: string; let testRoot: string;
const MAX_ZIP_COMMENT_BYTES = 0xffff;
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
beforeEach(async () => { beforeEach(async () => {
testRoot = await mkdtemp(join(tmpdir(), 'bookorbit-cbz-roundtrip-')); testRoot = await mkdtemp(join(tmpdir(), 'bookorbit-cbz-roundtrip-'));
}); });
@@ -23,9 +26,9 @@ function pageBytes(index: number): Buffer {
return bytes; return bytes;
} }
async function writeArchive(filePath: string, entries: Array<{ path: string; content: Buffer }>): Promise<void> { async function writeArchive(filePath: string, entries: Array<{ path: string; content: Buffer }>, comment = ''): Promise<void> {
const output = createWriteStream(filePath); const output = createWriteStream(filePath);
const archive = new ZipArchive({ zlib: { level: 6 } }); const archive = new ZipArchive({ zlib: { level: 6 }, comment });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
output.on('close', resolve); output.on('close', resolve);
@@ -37,6 +40,15 @@ async function writeArchive(filePath: string, entries: Array<{ path: string; con
}); });
} }
async function readArchiveComment(filePath: string): Promise<string> {
const contents = await readFile(filePath);
const endRecordOffset = contents.lastIndexOf(ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE);
if (endRecordOffset < 0) throw new Error('ZIP end of central directory record not found');
const commentLength = contents.readUInt16LE(endRecordOffset + 20);
return contents.subarray(endRecordOffset + 22, endRecordOffset + 22 + commentLength).toString('utf-8');
}
async function readArchive(filePath: string): Promise<Array<{ path: string; content: Buffer }>> { async function readArchive(filePath: string): Promise<Array<{ path: string; content: Buffer }>> {
const zip = await unzipper.Open.file(filePath); const zip = await unzipper.Open.file(filePath);
return Promise.all(zip.files.map(async (entry) => ({ path: entry.path, content: await entry.buffer() }))); return Promise.all(zip.files.map(async (entry) => ({ path: entry.path, content: await entry.buffer() })));
@@ -85,4 +97,15 @@ describe('cbz zip round trip', () => {
await expect(readdir(testRoot)).resolves.toEqual(['volume.cbz']); await expect(readdir(testRoot)).resolves.toEqual(['volume.cbz']);
}); });
it('updates comic info and preserves an archive comment at the ZIP format limit', async () => {
const target = join(testRoot, 'volume.cbz');
const archiveComment = 'x'.repeat(MAX_ZIP_COMMENT_BYTES);
await writeArchive(target, comicPages(3), archiveComment);
await writeComicInfoToZip(target, '<ComicInfo><Title>Updated</Title></ComicInfo>');
await expect(readComicInfoFromZip(target)).resolves.toBe('<ComicInfo><Title>Updated</Title></ComicInfo>');
await expect(readArchiveComment(target)).resolves.toBe(archiveComment);
});
}); });
@@ -10,14 +10,18 @@ export interface ZipRewriteEntry {
store?: boolean; store?: boolean;
} }
export interface ZipRewriteOptions {
comment?: string;
}
/** /**
* Archiver pipes an appended stream into its queue immediately, so appending every entry of a large * Archiver pipes an appended stream into its queue immediately, so appending every entry of a large
* archive up front holds one descriptor and one decompression buffer per entry until the rewrite * archive up front holds one descriptor and one decompression buffer per entry until the rewrite
* finishes. Entries are appended one at a time and their sources opened only once archiver is ready * finishes. Entries are appended one at a time and their sources opened only once archiver is ready
* for them, which keeps descriptors and memory flat regardless of entry count. * for them, which keeps descriptors and memory flat regardless of entry count.
*/ */
export async function writeZipArchive(tmpPath: string, entries: Iterable<ZipRewriteEntry>): Promise<void> { export async function writeZipArchive(tmpPath: string, entries: Iterable<ZipRewriteEntry>, options: ZipRewriteOptions = {}): Promise<void> {
const archive = new ZipArchive({ zlib: { level: ZLIB_COMPRESSION_LEVEL } }); const archive = new ZipArchive({ zlib: { level: ZLIB_COMPRESSION_LEVEL }, comment: options.comment });
const output = createWriteStream(tmpPath); const output = createWriteStream(tmpPath);
let fail!: (error: Error) => void; let fail!: (error: Error) => void;