mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user