From 43bf1b7cfa19ab4c9433cc7d33837cb4a7187f79 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:49:57 +0000 Subject: [PATCH] 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 --- .../file-write/formats/cbx/cbz-zip-patcher.ts | 23 +++++++++++++-- .../formats/cbx/cbz-zip-roundtrip.test.ts | 29 +++++++++++++++++-- .../file-write/formats/shared/zip-rewrite.ts | 8 +++-- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.ts b/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.ts index 04ea02123..39707f9f0 100644 --- a/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.ts +++ b/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.ts @@ -4,25 +4,42 @@ import * as unzipper from 'unzipper'; import { replaceFileAtomically } from '../shared/atomic-file-replace'; 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; + +async function openZipFile(filePath: string): Promise { + 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 { const normalized = entryPath.replace(/\\/g, '/').toLowerCase(); return normalized === 'comicinfo.xml' || normalized.endsWith('/comicinfo.xml'); } export async function readComicInfoFromZip(filePath: string): Promise { - const zip = await unzipper.Open.file(filePath); + const zip = await openZipFile(filePath); const entry = zip.files.find((f) => isComicInfoEntry(f.path)); if (!entry) return null; return (await entry.buffer()).toString('utf-8'); } export async function writeComicInfoToZip(filePath: string, xmlContent: string): Promise { - const zip = await unzipper.Open.file(filePath); + const zip = await openZipFile(filePath); const existing = zip.files.find((f) => isComicInfoEntry(f.path)); const xmlEntryPath = existing?.path ?? 'ComicInfo.xml'; 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); } diff --git a/server/src/modules/file-write/formats/cbx/cbz-zip-roundtrip.test.ts b/server/src/modules/file-write/formats/cbx/cbz-zip-roundtrip.test.ts index b11d77dfe..5825e0595 100644 --- a/server/src/modules/file-write/formats/cbx/cbz-zip-roundtrip.test.ts +++ b/server/src/modules/file-write/formats/cbx/cbz-zip-roundtrip.test.ts @@ -1,6 +1,6 @@ import { ZipArchive } from 'archiver'; import { createWriteStream } from 'fs'; -import { mkdtemp, readdir, rm } from 'fs/promises'; +import { mkdtemp, readFile, readdir, rm } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; import * as unzipper from 'unzipper'; @@ -9,6 +9,9 @@ import { readComicInfoFromZip, writeComicInfoToZip } from './cbz-zip-patcher'; let testRoot: string; +const MAX_ZIP_COMMENT_BYTES = 0xffff; +const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = Buffer.from([0x50, 0x4b, 0x05, 0x06]); + beforeEach(async () => { testRoot = await mkdtemp(join(tmpdir(), 'bookorbit-cbz-roundtrip-')); }); @@ -23,9 +26,9 @@ function pageBytes(index: number): Buffer { return bytes; } -async function writeArchive(filePath: string, entries: Array<{ path: string; content: Buffer }>): Promise { +async function writeArchive(filePath: string, entries: Array<{ path: string; content: Buffer }>, comment = ''): Promise { const output = createWriteStream(filePath); - const archive = new ZipArchive({ zlib: { level: 6 } }); + const archive = new ZipArchive({ zlib: { level: 6 }, comment }); await new Promise((resolve, reject) => { output.on('close', resolve); @@ -37,6 +40,15 @@ async function writeArchive(filePath: string, entries: Array<{ path: string; con }); } +async function readArchiveComment(filePath: string): Promise { + 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> { const zip = await unzipper.Open.file(filePath); 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']); }); + + 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, 'Updated'); + + await expect(readComicInfoFromZip(target)).resolves.toBe('Updated'); + await expect(readArchiveComment(target)).resolves.toBe(archiveComment); + }); }); diff --git a/server/src/modules/file-write/formats/shared/zip-rewrite.ts b/server/src/modules/file-write/formats/shared/zip-rewrite.ts index 6f67f81f9..7ca1ccf2b 100644 --- a/server/src/modules/file-write/formats/shared/zip-rewrite.ts +++ b/server/src/modules/file-write/formats/shared/zip-rewrite.ts @@ -10,14 +10,18 @@ export interface ZipRewriteEntry { store?: boolean; } +export interface ZipRewriteOptions { + comment?: string; +} + /** * 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 * 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. */ -export async function writeZipArchive(tmpPath: string, entries: Iterable): Promise { - const archive = new ZipArchive({ zlib: { level: ZLIB_COMPRESSION_LEVEL } }); +export async function writeZipArchive(tmpPath: string, entries: Iterable, options: ZipRewriteOptions = {}): Promise { + const archive = new ZipArchive({ zlib: { level: ZLIB_COMPRESSION_LEVEL }, comment: options.comment }); const output = createWriteStream(tmpPath); let fail!: (error: Error) => void;