mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): add CBX/CBZ format writers and ComicInfo support
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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;
|
||||
}
|
||||
Reference in New Issue
Block a user