feat(metadata): add CBX/CBZ format writers and ComicInfo support

This commit is contained in:
neon
2026-03-04 17:51:19 -06:00
parent 38a601675e
commit 7a0e66fe21
12 changed files with 646 additions and 73 deletions
@@ -26,22 +26,33 @@ async function rebuildEmbeddings() {
}
}
const writeSettings = ref<GlobalFileWriteSettings>({ ...DEFAULT_FILE_WRITE_SETTINGS })
const writeSettings = ref<GlobalFileWriteSettings>(structuredClone(DEFAULT_FILE_WRITE_SETTINGS))
const writeSaving = ref(false)
const writeSaved = ref(false)
const maxFileSizeMb = computed({
get: () => Math.round(writeSettings.value.maxFileSizeBytes / (1024 * 1024)),
const epubMaxMb = computed({
get: () => Math.round(writeSettings.value.epub.maxFileSizeBytes / (1024 * 1024)),
set: (mb: number) => {
writeSettings.value.maxFileSizeBytes = mb * 1024 * 1024
writeSettings.value.epub.maxFileSizeBytes = mb * 1024 * 1024
},
})
const pdfMaxMb = computed({
get: () => Math.round(writeSettings.value.pdf.maxFileSizeBytes / (1024 * 1024)),
set: (mb: number) => {
writeSettings.value.pdf.maxFileSizeBytes = mb * 1024 * 1024
},
})
const cbxMaxMb = computed({
get: () => Math.round(writeSettings.value.cbx.maxFileSizeBytes / (1024 * 1024)),
set: (mb: number) => {
writeSettings.value.cbx.maxFileSizeBytes = mb * 1024 * 1024
},
})
onMounted(async () => {
const res = await api('/api/v1/app-settings/file-write-settings')
if (res.ok) {
const data: GlobalFileWriteSettings = await res.json()
writeSettings.value = data
writeSettings.value = await res.json()
}
})
@@ -67,13 +78,19 @@ async function saveWriteSettings() {
}
}
function toggleEnabled() {
writeSettings.value.enabled = !writeSettings.value.enabled
function toggle(path: () => boolean, set: (v: boolean) => void) {
set(!path())
void saveWriteSettings()
}
function toggleWriteCover() {
writeSettings.value.writeCover = !writeSettings.value.writeCover
function toggleCbxFormat(fmt: 'cbz' | 'cb7') {
const formats = writeSettings.value.cbx.formats
const idx = formats.indexOf(fmt)
if (idx === -1) {
writeSettings.value.cbx.formats = [...formats, fmt]
} else {
writeSettings.value.cbx.formats = formats.filter((f) => f !== fmt)
}
void saveWriteSettings()
}
</script>
@@ -118,22 +135,30 @@ function toggleWriteCover() {
<div class="mt-8">
<p class="settings-group-label">Metadata File Sync</p>
<div class="border border-border rounded-lg bg-card px-5 py-5 space-y-5">
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileEdit :size="16" class="text-primary" />
</div>
<div class="flex-1">
<p class="settings-label">Write metadata to original files</p>
<p class="settings-hint leading-relaxed max-w-sm">
When enabled, saving metadata automatically updates the physical file on disk. Supports EPUB. Other formats are skipped.
</p>
<div class="border border-border rounded-lg bg-card divide-y divide-border">
<!-- Master toggle -->
<div class="px-5 py-4 flex items-start justify-between gap-6">
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileEdit :size="16" class="text-primary" />
</div>
<div>
<p class="settings-label">Write metadata to original files</p>
<p class="settings-hint leading-relaxed max-w-sm">
When enabled, saving metadata updates the physical file on disk. Configure each format below.
</p>
</div>
</div>
<button
class="relative shrink-0 w-10 h-6 rounded-full transition-colors disabled:opacity-50"
:class="writeSettings.enabled ? 'bg-primary' : 'bg-muted'"
:disabled="writeSaving"
@click="toggleEnabled"
@click="
toggle(
() => writeSettings.enabled,
(v) => (writeSettings.enabled = v),
)
"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"
@@ -142,33 +167,23 @@ function toggleWriteCover() {
</button>
</div>
<div v-if="writeSettings.enabled" class="pl-12 space-y-4">
<div class="flex items-center justify-between gap-4">
<template v-if="writeSettings.enabled">
<!-- Cover toggle -->
<div class="px-5 py-4 flex items-center justify-between gap-4">
<div>
<p class="settings-label text-sm">Max file size (MB)</p>
<p class="settings-hint text-xs">Files larger than this are skipped.</p>
</div>
<input
v-model.number="maxFileSizeMb"
type="number"
min="1"
max="2000"
:disabled="writeSaving"
class="w-24 text-sm border border-border rounded-md px-3 py-1.5 bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
@change="saveWriteSettings"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<p class="settings-label text-sm">Include cover image</p>
<p class="settings-hint text-xs">Writes the stored cover back into the file.</p>
<p class="settings-label">Include cover image</p>
<p class="settings-hint">Writes the stored cover back into the file (EPUB only).</p>
</div>
<button
class="relative shrink-0 w-10 h-6 rounded-full transition-colors disabled:opacity-50"
:class="writeSettings.writeCover ? 'bg-primary' : 'bg-muted'"
:disabled="writeSaving"
@click="toggleWriteCover"
@click="
toggle(
() => writeSettings.writeCover,
(v) => (writeSettings.writeCover = v),
)
"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"
@@ -176,12 +191,149 @@ function toggleWriteCover() {
/>
</button>
</div>
</div>
<p v-if="writeSaved" class="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 pl-12">
<!-- EPUB row -->
<div class="px-5 py-4 space-y-3">
<div class="flex items-center justify-between gap-4">
<div>
<p class="settings-label">EPUB</p>
<p class="settings-hint">Writes metadata into the OPF file inside the EPUB archive.</p>
</div>
<button
class="relative shrink-0 w-10 h-6 rounded-full transition-colors disabled:opacity-50"
:class="writeSettings.epub.enabled ? 'bg-primary' : 'bg-muted'"
:disabled="writeSaving"
@click="
toggle(
() => writeSettings.epub.enabled,
(v) => (writeSettings.epub.enabled = v),
)
"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"
:class="writeSettings.epub.enabled ? 'translate-x-4' : 'translate-x-0'"
/>
</button>
</div>
<div v-if="writeSettings.epub.enabled" class="flex items-center justify-between gap-4 pl-0">
<p class="text-sm text-muted-foreground">Max file size (MB)</p>
<input
v-model.number="epubMaxMb"
type="number"
min="1"
max="2000"
:disabled="writeSaving"
class="w-24 text-sm border border-border rounded-md px-3 py-1.5 bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
@change="saveWriteSettings"
/>
</div>
</div>
<!-- PDF row -->
<div class="px-5 py-4 space-y-3">
<div class="flex items-center justify-between gap-4">
<div>
<p class="settings-label">PDF</p>
<p class="settings-hint">Embeds metadata into PDF Info dictionary and XMP stream.</p>
</div>
<button
class="relative shrink-0 w-10 h-6 rounded-full transition-colors disabled:opacity-50"
:class="writeSettings.pdf.enabled ? 'bg-primary' : 'bg-muted'"
:disabled="writeSaving"
@click="
toggle(
() => writeSettings.pdf.enabled,
(v) => (writeSettings.pdf.enabled = v),
)
"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"
:class="writeSettings.pdf.enabled ? 'translate-x-4' : 'translate-x-0'"
/>
</button>
</div>
<div v-if="writeSettings.pdf.enabled" class="flex items-center justify-between gap-4">
<p class="text-sm text-muted-foreground">Max file size (MB)</p>
<input
v-model.number="pdfMaxMb"
type="number"
min="1"
max="2000"
:disabled="writeSaving"
class="w-24 text-sm border border-border rounded-md px-3 py-1.5 bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
@change="saveWriteSettings"
/>
</div>
</div>
<!-- Comic archives row -->
<div class="px-5 py-4 space-y-3">
<div class="flex items-center justify-between gap-4">
<div>
<p class="settings-label">Comic archives</p>
<p class="settings-hint">Writes ComicInfo.xml into CBZ and CB7 archives.</p>
</div>
<button
class="relative shrink-0 w-10 h-6 rounded-full transition-colors disabled:opacity-50"
:class="writeSettings.cbx.enabled ? 'bg-primary' : 'bg-muted'"
:disabled="writeSaving"
@click="
toggle(
() => writeSettings.cbx.enabled,
(v) => (writeSettings.cbx.enabled = v),
)
"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"
:class="writeSettings.cbx.enabled ? 'translate-x-4' : 'translate-x-0'"
/>
</button>
</div>
<template v-if="writeSettings.cbx.enabled">
<div class="flex items-center gap-2 flex-wrap">
<button
v-for="fmt in ['cbz', 'cb7'] as const"
:key="fmt"
class="flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border transition-colors disabled:opacity-50"
:class="
writeSettings.cbx.formats.includes(fmt)
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:border-primary/50'
"
:disabled="writeSaving"
@click="toggleCbxFormat(fmt)"
>
{{ fmt.toUpperCase() }}
</button>
<span
class="flex items-center px-3 py-1 rounded-full text-xs font-medium border border-border bg-muted text-muted-foreground cursor-default select-none"
>
CBR not writable
</span>
</div>
<div class="flex items-center justify-between gap-4">
<p class="text-sm text-muted-foreground">Max file size (MB)</p>
<input
v-model.number="cbxMaxMb"
type="number"
min="1"
max="5000"
:disabled="writeSaving"
class="w-24 text-sm border border-border rounded-md px-3 py-1.5 bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
@change="saveWriteSettings"
/>
</div>
</template>
</div>
</template>
<div v-if="writeSaved" class="px-5 py-3 flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<Check :size="12" />
Settings saved
</p>
</div>
</div>
</div>
</template>
+17 -2
View File
@@ -20,15 +20,30 @@ export type WriteLogEntry = {
errorMessage: string | null;
};
export interface GlobalFileWriteSettings {
export interface FormatWriteSettings {
enabled: boolean;
maxFileSizeBytes: number;
}
export interface CbxFormatWriteSettings {
enabled: boolean;
maxFileSizeBytes: number;
formats: ('cbz' | 'cb7')[];
}
export interface GlobalFileWriteSettings {
enabled: boolean;
writeCover: boolean;
epub: FormatWriteSettings;
pdf: FormatWriteSettings;
cbx: CbxFormatWriteSettings;
}
export const DEFAULT_FILE_WRITE_SETTINGS: GlobalFileWriteSettings = {
enabled: false,
maxFileSizeBytes: 104_857_600,
writeCover: true,
epub: { enabled: true, maxFileSizeBytes: 104_857_600 },
pdf: { enabled: true, maxFileSizeBytes: 104_857_600 },
cbx: { enabled: false, formats: ['cbz', 'cb7'], maxFileSizeBytes: 524_288_000 },
};
+10 -12
View File
@@ -1,20 +1,21 @@
import type { Params } from 'nestjs-pino';
import type { IncomingMessage, ServerResponse } from 'http';
import { RequestMethod } from '@nestjs/common';
const isDev = process.env.NODE_ENV !== 'production';
const FRAMEWORK_CONTEXTS = new Set(['InstanceLoader', 'RouterExplorer', 'RoutesResolver']);
const SILENT_URL_RE = /\/books\/\d+\/(thumbnail|cover)(\?|$)/;
export const loggerConfig: Params = {
exclude: [
{ method: RequestMethod.GET, path: 'books/:id/thumbnail' },
{ method: RequestMethod.GET, path: 'books/:id/cover' },
{ method: RequestMethod.GET, path: 'cbz/files/:id/pages/:page' },
],
pinoHttp: {
level: isDev ? 'debug' : 'info',
autoLogging: {
ignore: (req: IncomingMessage) => SILENT_URL_RE.test(req.url ?? ''),
},
hooks: {
logMethod(inputArgs, method) {
logMethod: function (inputArgs, method) {
const first = inputArgs[0];
if (
first !== null &&
@@ -24,8 +25,7 @@ export const loggerConfig: Params = {
) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return method.apply(this, inputArgs);
method.apply(this, inputArgs);
},
},
customProps: () => ({ context: 'HTTP' }),
@@ -41,10 +41,8 @@ export const loggerConfig: Params = {
return 'info';
},
serializers: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
req: (req: any) => ({ id: req.id as string, method: req.method as string, url: req.url as string }),
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
res: (res: any) => ({ statusCode: res.statusCode as number }),
req: (req: IncomingMessage & { id?: string }) => ({ id: req.id, method: req.method, url: req.url }),
res: (res: ServerResponse) => ({ statusCode: res.statusCode }),
},
...(isDev
? {
@@ -149,12 +149,14 @@ export class AppSettingsService {
const row = await this.db.query.appSettings.findFirst({
where: eq(schema.appSettings.key, APP_SETTING_KEYS.FILE_WRITE_SETTINGS),
});
return parseSafe<GlobalFileWriteSettings>(row?.value, { ...DEFAULT_FILE_WRITE_SETTINGS });
if (!row?.value) return { ...DEFAULT_FILE_WRITE_SETTINGS };
const stored = parseSafe<Partial<GlobalFileWriteSettings>>(row.value, {});
return mergeFileWriteSettings(DEFAULT_FILE_WRITE_SETTINGS, stored);
}
async updateFileWriteSettings(patch: Partial<GlobalFileWriteSettings>): Promise<GlobalFileWriteSettings> {
const current = await this.getFileWriteSettings();
const merged: GlobalFileWriteSettings = { ...current, ...patch };
const merged = mergeFileWriteSettings(current, patch);
const value = JSON.stringify(merged);
await this.db
.insert(schema.appSettings)
@@ -163,3 +165,13 @@ export class AppSettingsService {
return merged;
}
}
function mergeFileWriteSettings(base: GlobalFileWriteSettings, patch: Partial<GlobalFileWriteSettings>): GlobalFileWriteSettings {
return {
...base,
...patch,
epub: { ...base.epub, ...(patch.epub ?? {}) },
pdf: { ...base.pdf, ...(patch.pdf ?? {}) },
cbx: { ...base.cbx, ...(patch.cbx ?? {}) },
};
}
+36 -4
View File
@@ -36,6 +36,8 @@ function mimeForExt(name: string): string {
// ── CBZ: ZIP byte-offset scan ──────────────────────────────────────────────────
const LFH_SIG = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
// Data descriptor signature (optional but written by most tools)
const DD_SIG = Buffer.from([0x50, 0x4b, 0x07, 0x08]);
interface ZipEntry {
name: string;
@@ -46,6 +48,11 @@ interface ZipEntry {
// Scans local file headers directly — bypasses the central directory, which
// unzipper fails to locate when the ZIP has a large comment (e.g. ComicTagger metadata).
//
// When general purpose bit flag bit 3 is set the local header stores
// compressedSize=0 and the real size is in a data descriptor after the data.
// We resolve that by scanning for the descriptor signature and verifying the
// size field is self-consistent (dataStart + cSize === descriptorOffset).
function buildZipIndex(buf: Buffer): ZipEntry[] {
const entries: ZipEntry[] = [];
let pos = 0;
@@ -54,27 +61,49 @@ function buildZipIndex(buf: Buffer): ZipEntry[] {
const sigPos = buf.indexOf(LFH_SIG, pos);
if (sigPos === -1) break;
const flags = buf.readUInt16LE(sigPos + 6);
const compression = buf.readUInt16LE(sigPos + 8);
const compressedSize = buf.readUInt32LE(sigPos + 18);
let compressedSize = buf.readUInt32LE(sigPos + 18);
const fileNameLen = buf.readUInt16LE(sigPos + 26);
const extraLen = buf.readUInt16LE(sigPos + 28);
const dataStart = sigPos + 30 + fileNameLen + extraLen;
const fileName = buf.subarray(sigPos + 30, sigPos + 30 + fileNameLen).toString('utf-8');
// Bit 3: data descriptor present — local header has size=0.
if ((flags & 0x0008) !== 0 && compressedSize === 0) {
compressedSize = resolveDataDescriptorSize(buf, dataStart);
}
if (!fileName.endsWith('/') && !isHidden(fileName) && isImage(fileName)) {
if (compression === 0 || compression === 8) {
// Only add entries whose size we were able to resolve.
if ((compression === 0 || compression === 8) && compressedSize > 0) {
entries.push({ name: fileName, dataStart, compressedSize, compression });
}
}
pos = dataStart + compressedSize;
if (compressedSize === 0) pos = sigPos + 4;
pos = compressedSize > 0 ? dataStart + compressedSize : sigPos + 4;
}
entries.sort((a, b) => naturalSort(a.name, b.name));
return entries;
}
// Scans forward from dataStart for the data descriptor signature PK\x07\x08.
// Verifies self-consistency: the compressed-size field stored in the descriptor
// must equal (descriptorOffset - dataStart). This eliminates false positives
// where the compressed payload itself happens to contain the signature bytes.
function resolveDataDescriptorSize(buf: Buffer, dataStart: number): number {
let search = dataStart;
while (search < buf.length - 16) {
const idx = buf.indexOf(DD_SIG, search);
if (idx === -1) break;
const cSize = buf.readUInt32LE(idx + 8);
if (idx === dataStart + cSize) return cSize;
search = idx + 1;
}
return 0;
}
// ── CBR: node-unrar-js ─────────────────────────────────────────────────────────
// Buffer.buffer is a shared pool; slice out only the bytes for this buffer.
@@ -200,6 +229,9 @@ export class CbzService {
throw new NotFoundException(`Page ${pageIndex} out of range`);
}
const entry = entries[pageIndex];
if (entry.compressedSize === 0) {
throw new NotFoundException(`Page ${pageIndex} data could not be located in archive`);
}
const raw = createReadStream(file.absolutePath, {
start: entry.dataStart,
end: entry.dataStart + entry.compressedSize - 1,
@@ -27,7 +27,8 @@ export class FileWriteSettingsService {
});
if (!row) return { ...DEFAULT_FILE_WRITE_SETTINGS };
try {
return { ...DEFAULT_FILE_WRITE_SETTINGS, ...(JSON.parse(row.value) as Partial<GlobalFileWriteSettings>) };
const stored = JSON.parse(row.value) as Partial<GlobalFileWriteSettings>;
return mergeSettings(DEFAULT_FILE_WRITE_SETTINGS, stored);
} catch {
return { ...DEFAULT_FILE_WRITE_SETTINGS };
}
@@ -35,7 +36,7 @@ export class FileWriteSettingsService {
async updateGlobal(patch: Partial<GlobalFileWriteSettings>): Promise<GlobalFileWriteSettings> {
const current = await this.getGlobal();
const merged: GlobalFileWriteSettings = { ...current, ...patch };
const merged = mergeSettings(current, patch);
const value = JSON.stringify(merged);
await this.db
.insert(schema.appSettings)
@@ -44,3 +45,13 @@ export class FileWriteSettingsService {
return merged;
}
}
function mergeSettings(base: GlobalFileWriteSettings, patch: Partial<GlobalFileWriteSettings>): GlobalFileWriteSettings {
return {
...base,
...patch,
epub: { ...base.epub, ...(patch.epub ?? {}) },
pdf: { ...base.pdf, ...(patch.pdf ?? {}) },
cbx: { ...base.cbx, ...(patch.cbx ?? {}) },
};
}
@@ -8,6 +8,8 @@ import { FormatWriterRegistry } from './format-writer.registry';
import { FORMAT_WRITERS } from './interfaces/format-writer.interface';
import { EpubFormatWriter } from './formats/epub/epub-format-writer';
import { PdfFormatWriter } from './formats/pdf/pdf-format-writer';
import { CbzFormatWriter } from './formats/cbx/cbz-format-writer';
import { Cb7FormatWriter } from './formats/cbx/cb7-format-writer';
@Module({
providers: [
@@ -17,10 +19,12 @@ import { PdfFormatWriter } from './formats/pdf/pdf-format-writer';
FileLockService,
EpubFormatWriter,
PdfFormatWriter,
CbzFormatWriter,
Cb7FormatWriter,
{
provide: FORMAT_WRITERS,
useFactory: (epub: EpubFormatWriter, pdf: PdfFormatWriter) => [epub, pdf],
inject: [EpubFormatWriter, PdfFormatWriter],
useFactory: (epub: EpubFormatWriter, pdf: PdfFormatWriter, cbz: CbzFormatWriter, cb7: Cb7FormatWriter) => [epub, pdf, cbz, cb7],
inject: [EpubFormatWriter, PdfFormatWriter, CbzFormatWriter, Cb7FormatWriter],
},
FormatWriterRegistry,
],
@@ -3,14 +3,13 @@ import { ConfigService } from '@nestjs/config';
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import type { WriteResult } from '@projectx/types';
import type { WriteResult, GlobalFileWriteSettings } from '@projectx/types';
import { FileLockService } from './file-lock.service';
import { FileWriteRepository } from './file-write.repository';
import { FileWriteSettingsService } from './file-write-settings.service';
import { FormatWriterRegistry } from './format-writer.registry';
import type { BookWritePayload, BookWritePayloadKey } from './interfaces/book-write-payload.interface';
// All known payload fields for Phase 1 full-write mask
const ALL_FIELDS = new Set<BookWritePayloadKey>([
'title',
'subtitle',
@@ -91,10 +90,20 @@ export class FileWriteService implements OnModuleDestroy {
return { status: 'skipped', fieldsWritten: [], durationMs: 0, reason: 'disabled' };
}
const formatSettings = resolveFormatSettings(settings, format);
if (!formatSettings.enabled) {
const result: WriteResult = { status: 'skipped', fieldsWritten: [], durationMs: 0, reason: 'format disabled' };
this.logger.debug(`Write skipped for book ${bookId}: format disabled (${format})`);
if (triggeredBy === 'sync') {
await this.fileWriteRepo.insertLog({ bookId, bookFileId: file.id, userId: userId ?? null, format, result, triggeredBy });
}
return result;
}
const sizeBytes = file.sizeBytes ?? 0;
if (sizeBytes > settings.maxFileSizeBytes) {
if (sizeBytes > formatSettings.maxFileSizeBytes) {
const result: WriteResult = { status: 'skipped', fieldsWritten: [], durationMs: 0, reason: 'file exceeds size limit' };
this.logger.debug(`Write skipped for book ${bookId}: file size ${sizeBytes} exceeds limit ${settings.maxFileSizeBytes}`);
this.logger.debug(`Write skipped for book ${bookId}: file size ${sizeBytes} exceeds limit ${formatSettings.maxFileSizeBytes}`);
if (triggeredBy === 'sync') {
await this.fileWriteRepo.insertLog({ bookId, bookFileId: file.id, userId: userId ?? null, format, result, triggeredBy });
}
@@ -144,3 +153,20 @@ export class FileWriteService implements OnModuleDestroy {
}
}
}
function resolveFormatSettings(settings: GlobalFileWriteSettings, format: string): { enabled: boolean; maxFileSizeBytes: number } {
switch (format) {
case 'epub':
return settings.epub;
case 'pdf':
return settings.pdf;
case 'cbz':
case 'cb7':
return {
enabled: settings.cbx.enabled && (settings.cbx.formats as string[]).includes(format),
maxFileSizeBytes: settings.cbx.maxFileSizeBytes,
};
default:
return { enabled: false, maxFileSizeBytes: 0 };
}
}
@@ -0,0 +1,121 @@
import { Injectable } from '@nestjs/common';
import { readFile, rename, unlink, writeFile } from 'fs/promises';
import { dirname, join } from 'path';
import { randomUUID } from 'crypto';
import type { WriteResult } from '@projectx/types';
import { getSevenZip, type SevenZipFS } from '../../../../common/sevenzip';
import type { BookWritePayload } from '../../interfaces/book-write-payload.interface';
import type { FormatWriter } from '../../interfaces/format-writer.interface';
import type { FormatWriteOptions } from '../../interfaces/format-write-options.interface';
import { resolveFieldsWritten } from '../shared/resolve-fields-written';
import { buildComicInfoXml } from './comic-info-builder';
// All WASM FS ops are synchronous — Node's single-threaded event loop guarantees
// that synchronous blocks from concurrent writes never interleave with each other.
// The archive path uses a unique ID to avoid VFS collisions across concurrent writes.
// The XML path is shared but safe: it is only used within synchronous blocks and
// is always written before it is added to the archive, with no await in between.
const VFS_XML_PATH = '/ComicInfo.xml';
@Injectable()
export class Cb7FormatWriter implements FormatWriter {
readonly format = 'cb7';
async write(filePath: string, payload: BookWritePayload, options: FormatWriteOptions): Promise<WriteResult> {
const start = Date.now();
const { fieldMask, dryRun } = options;
const fieldsWritten = resolveFieldsWritten(payload, fieldMask);
if (dryRun) {
return { status: 'skipped', reason: 'dry-run', fieldsWritten, durationMs: Date.now() - start };
}
const uid = randomUUID().replace(/-/g, '');
const vfsArchivePath = `/cbx-arc-${uid}`;
const vfsExtractDir = `/cbx-ext-${uid}`;
const sz = await getSevenZip();
try {
const archiveBytes = await readFile(filePath);
// --- synchronous block start: no awaits until we're done with the VFS ---
const fd = sz.FS.open(vfsArchivePath, 'w+');
sz.FS.write(fd, archiveBytes, 0, archiveBytes.length);
sz.FS.close(fd);
let existingXml: string | null = null;
try {
sz.FS.mkdir(vfsExtractDir);
sz.callMain(['e', vfsArchivePath, `-o${vfsExtractDir}`, 'ComicInfo.xml', '-y']);
const raw = sz.FS.readFile(`${vfsExtractDir}/ComicInfo.xml`);
existingXml = Buffer.from(raw).toString('utf-8');
} catch {
// No ComicInfo.xml in archive — start fresh
}
const xml = buildComicInfoXml(existingXml, payload, fieldMask);
const xmlBytes = Buffer.from(xml, 'utf-8');
try {
sz.FS.unlink(VFS_XML_PATH);
} catch {
// Not present — expected on first write
}
const fd2 = sz.FS.open(VFS_XML_PATH, 'w+');
sz.FS.write(fd2, xmlBytes, 0, xmlBytes.length);
sz.FS.close(fd2);
// Delete old entry (if any) then add the updated one.
// 7z strips the leading / from absolute VFS paths, so /ComicInfo.xml
// is stored in the archive as ComicInfo.xml at the root.
sz.callMain(['d', vfsArchivePath, 'ComicInfo.xml', '-y']);
sz.callMain(['a', vfsArchivePath, VFS_XML_PATH]);
const modifiedBytes = sz.FS.readFile(vfsArchivePath);
// --- synchronous block end ---
const tmpPath = join(dirname(filePath), `.cbx-write-${uid}`);
await writeFile(tmpPath, Buffer.from(modifiedBytes));
try {
await rename(tmpPath, filePath);
} catch (err) {
await unlink(tmpPath).catch(() => {});
throw err;
}
return { status: 'success', fieldsWritten, durationMs: Date.now() - start };
} finally {
cleanupVfs(sz.FS, vfsArchivePath, vfsExtractDir);
}
}
}
function cleanupVfs(vfs: SevenZipFS, archivePath: string, extractDir: string): void {
try {
vfs.unlink(VFS_XML_PATH);
} catch {
// Already deleted by a concurrent write's cleanup — that's fine
}
try {
vfs.unlink(archivePath);
} catch {
/* ignore */
}
try {
for (const f of vfs.readdir(extractDir)) {
if (f === '.' || f === '..') continue;
try {
vfs.unlink(`${extractDir}/${f}`);
} catch {
/* ignore */
}
}
vfs.rmdir(extractDir);
} catch {
/* ignore */
}
}
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import type { WriteResult } from '@projectx/types';
import type { BookWritePayload } from '../../interfaces/book-write-payload.interface';
import type { FormatWriter } from '../../interfaces/format-writer.interface';
import type { FormatWriteOptions } from '../../interfaces/format-write-options.interface';
import { resolveFieldsWritten } from '../shared/resolve-fields-written';
import { buildComicInfoXml } from './comic-info-builder';
import { readComicInfoFromZip, writeComicInfoToZip } from './cbz-zip-patcher';
@Injectable()
export class CbzFormatWriter implements FormatWriter {
readonly format = 'cbz';
async write(filePath: string, payload: BookWritePayload, options: FormatWriteOptions): Promise<WriteResult> {
const start = Date.now();
const { fieldMask, dryRun } = options;
const fieldsWritten = resolveFieldsWritten(payload, fieldMask);
if (dryRun) {
return { status: 'skipped', reason: 'dry-run', fieldsWritten, durationMs: Date.now() - start };
}
const existingXml = await readComicInfoFromZip(filePath);
const xml = buildComicInfoXml(existingXml, payload, fieldMask);
await writeComicInfoToZip(filePath, xml);
return { status: 'success', fieldsWritten, durationMs: Date.now() - start };
}
}
@@ -0,0 +1,50 @@
import * as fs from 'fs/promises';
import { createWriteStream } from 'fs';
import { dirname, join } from 'path';
import { randomUUID } from 'crypto';
import * as unzipper from 'unzipper';
import archiver from 'archiver';
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<string | null> {
const zip = await unzipper.Open.file(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<void> {
const zip = await unzipper.Open.file(filePath);
const existing = zip.files.find((f) => isComicInfoEntry(f.path));
const xmlEntryPath = existing?.path ?? 'ComicInfo.xml';
const tmpPath = join(dirname(filePath), `.cbx-write-${randomUUID()}`);
const archive = archiver('zip', { zlib: { level: 6 } });
const output = createWriteStream(tmpPath);
await new Promise<void>((resolve, reject) => {
output.on('close', resolve);
output.on('error', reject);
archive.on('error', reject);
archive.pipe(output);
for (const entry of zip.files) {
if (isComicInfoEntry(entry.path)) continue;
archive.append(entry.stream(), { name: entry.path });
}
archive.append(Buffer.from(xmlContent, 'utf-8'), { name: xmlEntryPath });
void archive.finalize();
});
try {
await fs.rename(tmpPath, filePath);
} catch (err) {
await fs.unlink(tmpPath).catch(() => {});
throw err;
}
}
@@ -0,0 +1,122 @@
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface';
type ComicInfoObject = Record<string, unknown>;
const PARSER = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
parseTagValue: true,
trimValues: true,
});
const BUILDER = new XMLBuilder({
ignoreAttributes: false,
attributeNamePrefix: '@_',
format: true,
indentBy: ' ',
suppressBooleanAttributes: false,
});
function parseComicInfoXml(xml: string): ComicInfoObject {
try {
const parsed = PARSER.parse(xml) as { ComicInfo?: ComicInfoObject };
return parsed.ComicInfo ?? {};
} catch {
return {};
}
}
export function buildComicInfoXml(existingXml: string | null, payload: BookWritePayload, fieldMask: Set<BookWritePayloadKey>): string {
const info: ComicInfoObject = existingXml ? parseComicInfoXml(existingXml) : {};
if (fieldMask.has('title') && payload.title != null) info['Title'] = payload.title;
if (fieldMask.has('description') && payload.description != null) info['Summary'] = stripHtml(payload.description);
if (fieldMask.has('publisher') && payload.publisher != null) info['Publisher'] = payload.publisher;
if (fieldMask.has('seriesName') && payload.seriesName != null) info['Series'] = payload.seriesName;
if (fieldMask.has('seriesIndex') && payload.seriesIndex != null) info['Number'] = formatSeriesIndex(payload.seriesIndex);
if (fieldMask.has('publishedYear') && payload.publishedYear != null) info['Year'] = payload.publishedYear;
if (fieldMask.has('pageCount') && payload.pageCount != null) info['PageCount'] = payload.pageCount;
if (fieldMask.has('language') && payload.language != null) info['LanguageISO'] = payload.language;
if (fieldMask.has('authors') && payload.authors?.length) info['Writer'] = payload.authors.map((a) => a.name).join(', ');
if (fieldMask.has('genres') && payload.genres?.length) info['Genre'] = payload.genres.join(', ');
if (fieldMask.has('tags') && payload.tags?.length) info['Tags'] = payload.tags.join(', ');
if (fieldMask.has('rating') && payload.rating != null) info['CommunityRating'] = formatRating(payload.rating);
if (fieldMask.has('isbn13') && payload.isbn13 != null) info['GTIN'] = payload.isbn13;
const webUrl = resolveWebUrl(payload);
if (webUrl != null) info['Web'] = webUrl;
const existingNotes = typeof info['Notes'] === 'string' ? info['Notes'] : null;
const notes = buildNotes(existingNotes, payload);
if (notes != null) {
info['Notes'] = notes;
} else {
delete info['Notes'];
}
if (!info['@_xmlns:xsi']) {
info['@_xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance';
info['@_xmlns:xsd'] = 'http://www.w3.org/2001/XMLSchema';
}
const xmlBody = BUILDER.build({ ComicInfo: info });
return `<?xml version="1.0" encoding="UTF-8"?>\n${xmlBody}`;
}
function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, ' ')
.replace(/\s{2,}/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
}
function formatSeriesIndex(val: number): string {
return val % 1 === 0 ? String(Math.trunc(val)) : String(val);
}
function formatRating(val: number): string {
return Math.min(5.0, Math.max(0.0, val / 2.0)).toFixed(1);
}
function resolveWebUrl(payload: BookWritePayload): string | null {
if (payload.goodreadsId) return `https://www.goodreads.com/book/show/${payload.goodreadsId}`;
if (payload.amazonId) return `https://www.amazon.com/dp/${payload.amazonId}`;
if (payload.hardcoverId) return `https://hardcover.app/books/${payload.hardcoverId}`;
if (payload.googleBooksId) return `https://books.google.com/books?id=${payload.googleBooksId}`;
if (payload.openLibraryId) return `https://openlibrary.org/works/${payload.openLibraryId}`;
return null;
}
function buildNotes(existing: string | null, payload: BookWritePayload): string | null {
const lines: string[] = [];
if (existing) {
for (const line of existing.split('\n')) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('[projectx:')) lines.push(trimmed);
}
}
const ids: [string, string | null | undefined][] = [
['subtitle', payload.subtitle],
['isbn10', payload.isbn10],
['goodreadsId', payload.goodreadsId],
['amazonId', payload.amazonId],
['hardcoverId', payload.hardcoverId],
['googleBooksId', payload.googleBooksId],
['openLibraryId', payload.openLibraryId],
];
for (const [key, val] of ids) {
if (val != null && val !== '') lines.push(`[projectx:${key}] ${val}`);
}
return lines.length > 0 ? lines.join('\n') : null;
}