feat(reader): add CBR and CB7 comic support with cover and metadata

This commit is contained in:
neon
2026-01-03 09:16:03 -06:00
parent dd5b2700da
commit 9ca37cfbec
12 changed files with 403 additions and 59 deletions
+1 -1
View File
@@ -222,7 +222,7 @@ function navigateSearch(cfiTarget: string) {
<template>
<PdfReaderView v-if="fileFormat === 'pdf'" :bookId="bookId" :fileId="fileId" />
<CbzReaderView v-else-if="fileFormat === 'cbz' || fileFormat === 'cbr'" :bookId="bookId" :fileId="fileId" />
<CbzReaderView v-else-if="fileFormat === 'cbz' || fileFormat === 'cbr' || fileFormat === 'cb7'" :bookId="bookId" :fileId="fileId" />
<div
v-else
class="fixed inset-0 overflow-hidden"
+2 -2
View File
@@ -24,7 +24,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"7zip-bin": "^5.2.0",
"7z-wasm": "^1.2.0",
"@fastify/static": "^9.0.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
@@ -37,7 +37,7 @@
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"fast-xml-parser": "^5.3.3",
"node-7z": "^3.0.0",
"node-unrar-js": "^2.0.2",
"pdf-parse": "^2.4.5",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
+29
View File
@@ -0,0 +1,29 @@
// Shared 7z-wasm singleton — one WASM instance for the server lifetime.
// Both the comic reader service and metadata extraction use this.
export interface SevenZipFS {
open(path: string, flags: string): number;
write(fd: number, buf: Uint8Array, offset: number, length: number): number;
close(fd: number): void;
mkdir(path: string): void;
readdir(path: string): string[];
readFile(path: string): Uint8Array;
unlink(path: string): void;
rmdir(path: string): void;
}
export interface SevenZipModule {
FS: SevenZipFS;
callMain(args: string[]): void;
}
let _instance: SevenZipModule | null = null;
export async function getSevenZip(): Promise<SevenZipModule> {
if (!_instance) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const factory = require('7z-wasm') as (opts?: object) => Promise<SevenZipModule>;
_instance = await factory();
}
return _instance;
}
+4 -4
View File
@@ -5,28 +5,28 @@
{
"idx": 0,
"version": "7",
"when": 1767494438065,
"when": 1767500010285,
"tag": "0000_init_schema",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1767494438065,
"when": 1767500010285,
"tag": "0001_lethal_pixie",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1767494438065,
"when": 1767500010285,
"tag": "0002_powerful_thundra",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1767494438065,
"when": 1767500010285,
"tag": "0003_reading_progress_by_file",
"breakpoints": true
}
+165 -31
View File
@@ -1,12 +1,16 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Readable } from 'stream';
import { createReadStream } from 'fs';
import { readFile } from 'fs/promises';
import { createInflateRaw } from 'zlib';
import { createExtractorFromData } from 'node-unrar-js';
import { getSevenZip } from '../../common/sevenzip';
import { BookRepository } from '../book/book.repository';
// ── Shared helpers ─────────────────────────────────────────────────────────────
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp', '.avif']);
const LFH_SIG = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
function isImage(name: string): boolean {
const dot = name.lastIndexOf('.');
@@ -17,18 +21,32 @@ function isHidden(name: string): boolean {
return name.split('/').some((p) => p.startsWith('.'));
}
interface PageEntry {
function naturalSort(a: string, b: string): number {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
}
function mimeForExt(name: string): string {
const ext = name.substring(name.lastIndexOf('.')).toLowerCase();
if (ext === '.png') return 'image/png';
if (ext === '.webp') return 'image/webp';
return 'image/jpeg';
}
// ── CBZ: ZIP byte-offset scan ──────────────────────────────────────────────────
const LFH_SIG = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
interface ZipEntry {
name: string;
dataStart: number; // byte offset of compressed data in the ZIP file
dataStart: number;
compressedSize: number;
compression: number; // 0 = STORED, 8 = DEFLATE
}
// Scan all local file headers in the buffer and return image entries sorted naturally.
// Same technique as cover-cbz.ts — bypasses the central directory, which unzipper
// fails to locate when the ZIP has a large comment (e.g. ComicTagger JSON metadata).
function buildPageIndex(buf: Buffer): PageEntry[] {
const entries: PageEntry[] = [];
// 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).
function buildZipIndex(buf: Buffer): ZipEntry[] {
const entries: ZipEntry[] = [];
let pos = 0;
while (pos < buf.length - 30) {
@@ -49,51 +67,167 @@ function buildPageIndex(buf: Buffer): PageEntry[] {
}
pos = dataStart + compressedSize;
if (compressedSize === 0) pos = sigPos + 4; // guard against infinite loop on data descriptors
if (compressedSize === 0) pos = sigPos + 4;
}
entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
entries.sort((a, b) => naturalSort(a.name, b.name));
return entries;
}
// ── CBR: node-unrar-js ─────────────────────────────────────────────────────────
// Buffer.buffer is a shared pool; slice out only the bytes for this buffer.
function toArrayBuffer(buf: Buffer): ArrayBuffer {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
}
// ── Service ────────────────────────────────────────────────────────────────────
interface RarCache {
buffer: ArrayBuffer;
pages: string[];
}
@Injectable()
export class CbzService {
// Caches sorted page entries per fileId — built once on first access, held for server lifetime.
private readonly indexCache = new Map<number, PageEntry[]>();
// CBZ: byte-offset index per fileId
private readonly zipIndex = new Map<number, ZipEntry[]>();
// CBR: archive ArrayBuffer + sorted page names per fileId
private readonly rarCache = new Map<number, RarCache>();
// CB7: sorted page names per fileId (extracted files live in WASM VFS)
private readonly sevenZPages = new Map<number, string[]>();
constructor(private readonly bookRepo: BookRepository) {}
private async getIndex(fileId: number): Promise<{ path: string; entries: PageEntry[] }> {
private async getFile(fileId: number) {
const file = await this.bookRepo.findFileById(fileId);
if (!file) throw new NotFoundException(`File ${fileId} not found`);
if (!this.indexCache.has(fileId)) {
const buf = await readFile(file.absolutePath);
this.indexCache.set(fileId, buildPageIndex(buf));
}
return { path: file.absolutePath, entries: this.indexCache.get(fileId)! };
return file;
}
// ── CBZ ──────────────────────────────────────────────────────────────────────
private async getCbzIndex(fileId: number, filePath: string): Promise<ZipEntry[]> {
if (!this.zipIndex.has(fileId)) {
const buf = await readFile(filePath);
this.zipIndex.set(fileId, buildZipIndex(buf));
}
return this.zipIndex.get(fileId)!;
}
// ── CBR ──────────────────────────────────────────────────────────────────────
private async getRarCache(fileId: number, filePath: string): Promise<RarCache> {
if (!this.rarCache.has(fileId)) {
const buf = await readFile(filePath);
const ab = toArrayBuffer(buf);
const extractor = await createExtractorFromData({ data: ab });
const { fileHeaders } = extractor.getFileList();
const pages = [...fileHeaders]
.filter((h) => !h.flags.directory && isImage(h.name) && !isHidden(h.name))
.map((h) => h.name)
.sort(naturalSort);
this.rarCache.set(fileId, { buffer: ab, pages });
}
return this.rarCache.get(fileId)!;
}
private async extractRarPage(buffer: ArrayBuffer, pageName: string): Promise<Uint8Array> {
const extractor = await createExtractorFromData({ data: buffer });
const { files } = extractor.extract({ files: (h) => h.name === pageName });
// Generators MUST be fully drained to avoid WASM memory leak
let result: Uint8Array | undefined;
for (const file of files) {
if (!file.fileHeader.flags.directory) result = file.extraction;
}
if (result === undefined) throw new NotFoundException(`Page not found in RAR archive`);
return result;
}
// ── CB7 ──────────────────────────────────────────────────────────────────────
private async getSevenZPages(fileId: number, filePath: string): Promise<string[]> {
if (!this.sevenZPages.has(fileId)) {
const sz = await getSevenZip();
const archivePath = `/a${fileId}`;
const outDir = `/p${fileId}`;
const buf = await readFile(filePath);
const fd = sz.FS.open(archivePath, 'w+');
sz.FS.write(fd, buf, 0, buf.length);
sz.FS.close(fd);
try {
sz.FS.mkdir(outDir);
} catch {
// already exists from a previous run
}
// callMain is synchronous — blocks the event loop while 7z extracts.
// Acceptable for a local single-user server; revisit with worker_threads if needed.
sz.callMain(['e', archivePath, `-o${outDir}`, '-y']);
const files = sz.FS.readdir(outDir);
const pages = files.filter((f) => f !== '.' && f !== '..' && isImage(f) && !isHidden(f)).sort(naturalSort);
this.sevenZPages.set(fileId, pages);
}
return this.sevenZPages.get(fileId)!;
}
// ── Public API ────────────────────────────────────────────────────────────────
async getPageCount(fileId: number): Promise<number> {
const { entries } = await this.getIndex(fileId);
return entries.length;
const file = await this.getFile(fileId);
const fmt = file.format ?? '';
if (fmt === 'cbz') return (await this.getCbzIndex(fileId, file.absolutePath)).length;
if (fmt === 'cbr') return (await this.getRarCache(fileId, file.absolutePath)).pages.length;
if (fmt === 'cb7') return (await this.getSevenZPages(fileId, file.absolutePath)).length;
throw new NotFoundException(`Unsupported comic format: ${fmt}`);
}
async streamPage(fileId: number, pageIndex: number): Promise<{ stream: NodeJS.ReadableStream; mimeType: string }> {
const { path, entries } = await this.getIndex(fileId);
const file = await this.getFile(fileId);
const fmt = file.format ?? '';
if (pageIndex < 0 || pageIndex >= entries.length) {
throw new NotFoundException(`Page ${pageIndex} out of range (0–${entries.length - 1})`);
if (fmt === 'cbz') {
const entries = await this.getCbzIndex(fileId, file.absolutePath);
if (pageIndex < 0 || pageIndex >= entries.length) {
throw new NotFoundException(`Page ${pageIndex} out of range`);
}
const entry = entries[pageIndex];
const raw = createReadStream(file.absolutePath, {
start: entry.dataStart,
end: entry.dataStart + entry.compressedSize - 1,
});
const stream = entry.compression === 0 ? raw : raw.pipe(createInflateRaw());
return { stream, mimeType: mimeForExt(entry.name) };
}
const entry = entries[pageIndex];
const ext = entry.name.substring(entry.name.lastIndexOf('.')).toLowerCase();
const mimeType = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
if (fmt === 'cbr') {
const { buffer, pages } = await this.getRarCache(fileId, file.absolutePath);
if (pageIndex < 0 || pageIndex >= pages.length) {
throw new NotFoundException(`Page ${pageIndex} out of range`);
}
const data = await this.extractRarPage(buffer, pages[pageIndex]);
return { stream: Readable.from(Buffer.from(data)), mimeType: mimeForExt(pages[pageIndex]) };
}
const raw = createReadStream(path, { start: entry.dataStart, end: entry.dataStart + entry.compressedSize - 1 });
const stream = entry.compression === 0 ? raw : raw.pipe(createInflateRaw());
if (fmt === 'cb7') {
const pages = await this.getSevenZPages(fileId, file.absolutePath);
if (pageIndex < 0 || pageIndex >= pages.length) {
throw new NotFoundException(`Page ${pageIndex} out of range`);
}
const sz = await getSevenZip();
const data = sz.FS.readFile(`/p${fileId}/${pages[pageIndex]}`);
return { stream: Readable.from(Buffer.from(data)), mimeType: mimeForExt(pages[pageIndex]) };
}
return { stream, mimeType };
throw new NotFoundException(`Unsupported comic format: ${fmt}`);
}
}
@@ -1,6 +1,8 @@
import { readFile } from 'fs/promises';
import { XMLParser } from 'fast-xml-parser';
import { inflateRawSync } from 'zlib';
import { createExtractorFromData } from 'node-unrar-js';
import { getSevenZip } from '../../../common/sevenzip';
// ── ZIP binary constants ──────────────────────────────────────────────────────
@@ -143,6 +145,10 @@ function parseComicBookInfoJson(comment: string): ParsedCbzMetadata | null {
// ── Public API ────────────────────────────────────────────────────────────────
function toArrayBuffer(buf: Buffer): ArrayBuffer {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
}
/**
* Extract metadata from a CBZ file.
* Tries ComicInfo.xml first (embedded file), then ComicBookInfo JSON (ZIP comment).
@@ -168,3 +174,71 @@ export async function extractCbzMetadata(absolutePath: string): Promise<ParsedCb
return null;
}
}
/** Extract ComicInfo.xml metadata from a CBR (RAR) file. */
export async function extractCbrMetadata(absolutePath: string): Promise<ParsedCbzMetadata | null> {
try {
const buf = await readFile(absolutePath);
const ab = toArrayBuffer(buf);
const extractor = await createExtractorFromData({ data: ab });
const { files } = extractor.extract({ files: (h) => h.name.toLowerCase() === 'comicinfo.xml' });
let xmlBuf: Buffer | undefined;
for (const file of files) {
if (!file.fileHeader.flags.directory && file.extraction) xmlBuf = Buffer.from(file.extraction);
}
return xmlBuf ? parseComicInfoXml(xmlBuf) : null;
} catch {
return null;
}
}
/** Extract ComicInfo.xml metadata from a CB7 (7-Zip) file. */
export async function extractCb7Metadata(absolutePath: string): Promise<ParsedCbzMetadata | null> {
try {
const sz = await getSevenZip();
const buf = await readFile(absolutePath);
const id = `meta_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const archPath = `/${id}`;
const outDir = `/${id}_out`;
const fd = sz.FS.open(archPath, 'w+');
sz.FS.write(fd, buf, 0, buf.length);
sz.FS.close(fd);
try {
sz.FS.mkdir(outDir);
} catch {
// already exists
}
// Extract only ComicInfo.xml — avoids decompressing the whole solid block.
sz.callMain(['e', archPath, `-o${outDir}`, 'ComicInfo.xml', '-y']);
let result: ParsedCbzMetadata | null = null;
try {
const xmlData = sz.FS.readFile(`${outDir}/ComicInfo.xml`);
result = parseComicInfoXml(Buffer.from(xmlData));
} catch {
// ComicInfo.xml not present — that's fine
}
// Clean up WASM VFS.
try {
for (const f of sz.FS.readdir(outDir).filter((f) => f !== '.' && f !== '..')) {
sz.FS.unlink(`${outDir}/${f}`);
}
sz.FS.rmdir(outDir);
sz.FS.unlink(archPath);
} catch {
// best-effort
}
return result;
} catch {
return null;
}
}
@@ -0,0 +1,54 @@
import { readFile } from 'fs/promises';
import { getSevenZip } from '../../../common/sevenzip';
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']);
function isImage(name: string): boolean {
const dot = name.lastIndexOf('.');
return dot !== -1 && IMAGE_EXTS.has(name.substring(dot).toLowerCase());
}
export async function extractCb7Cover(absolutePath: string): Promise<Buffer | null> {
try {
const sz = await getSevenZip();
const buf = await readFile(absolutePath);
// Use a unique ID to avoid VFS path collisions between concurrent requests.
const id = `cover_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const archPath = `/${id}`;
const outDir = `/${id}_out`;
const fd = sz.FS.open(archPath, 'w+');
sz.FS.write(fd, buf, 0, buf.length);
sz.FS.close(fd);
try {
sz.FS.mkdir(outDir);
} catch {
// already exists
}
sz.callMain(['e', archPath, `-o${outDir}`, '-y']);
const files = sz.FS.readdir(outDir)
.filter((f) => f !== '.' && f !== '..' && isImage(f))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
const result = files.length > 0 ? Buffer.from(sz.FS.readFile(`${outDir}/${files[0]}`)) : null;
// Clean up WASM VFS — cover extraction is a one-shot operation.
try {
for (const f of sz.FS.readdir(outDir).filter((f) => f !== '.' && f !== '..')) {
sz.FS.unlink(`${outDir}/${f}`);
}
sz.FS.rmdir(outDir);
sz.FS.unlink(archPath);
} catch {
// cleanup is best-effort
}
return result;
} catch {
return null;
}
}
@@ -0,0 +1,46 @@
import { readFile } from 'fs/promises';
import { createExtractorFromData } from 'node-unrar-js';
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']);
function isImage(name: string): boolean {
const dot = name.lastIndexOf('.');
return dot !== -1 && IMAGE_EXTS.has(name.substring(dot).toLowerCase());
}
function isHidden(name: string): boolean {
return name.split('/').some((p) => p.startsWith('.'));
}
function toArrayBuffer(buf: Buffer): ArrayBuffer {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
}
export async function extractCbrCover(absolutePath: string): Promise<Buffer | null> {
try {
const buf = await readFile(absolutePath);
const ab = toArrayBuffer(buf);
// First pass: list files to find the first image, sorted naturally.
const listExtractor = await createExtractorFromData({ data: ab });
const { fileHeaders } = listExtractor.getFileList();
const images = [...fileHeaders]
.filter((h) => !h.flags.directory && isImage(h.name) && !isHidden(h.name))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
if (images.length === 0) return null;
const firstName = images[0].name;
// Second pass: extract only that file. Must drain the generator fully to avoid WASM leak.
const extractExtractor = await createExtractorFromData({ data: ab });
const { files } = extractExtractor.extract({ files: (h) => h.name === firstName });
let result: Uint8Array | undefined;
for (const file of files) {
if (!file.fileHeader.flags.directory) result = file.extraction;
}
return result ? Buffer.from(result) : null;
} catch {
return null;
}
}
+6
View File
@@ -2,6 +2,8 @@ import { mkdir, writeFile } from 'fs/promises';
import { join } from 'path';
import sharp from 'sharp';
import { extractCb7Cover } from './cover-cb7';
import { extractCbrCover } from './cover-cbr';
import { extractCbzCover } from './cover-cbz';
import { extractEpubCover } from './cover-epub';
import { extractMobiCover } from './mobi-parser';
@@ -29,6 +31,10 @@ export async function extractCover(absolutePath: string, format: string): Promis
return extractMobiCover(absolutePath);
case 'cbz':
return extractCbzCover(absolutePath);
case 'cbr':
return extractCbrCover(absolutePath);
case 'cb7':
return extractCb7Cover(absolutePath);
default:
return null;
}
@@ -8,7 +8,7 @@ import * as schema from '../../db/schema';
import { authors, bookAuthors, bookMetadata, bookTags, tags } from '../../db/schema';
import sharp from 'sharp';
import { extractCbzMetadata } from './lib/cbz-metadata';
import { extractCb7Metadata, extractCbrMetadata, extractCbzMetadata } from './lib/cbz-metadata';
import { extractAndSaveCover } from './lib/cover';
import { extractEpubMetadata } from './lib/epub';
import { parseBookFilename } from './lib/filename-parser';
@@ -55,7 +55,7 @@ export class MetadataService {
if (format === 'epub') {
parsed = await extractEpubMetadata(absolutePath);
} else if (format === 'cbz' || format === 'cbr') {
} else if (format === 'cbz') {
const cbz = await extractCbzMetadata(absolutePath);
if (cbz) {
parsed = {
@@ -73,6 +73,24 @@ export class MetadataService {
tags: cbz.tags,
};
}
} else if (format === 'cbr' || format === 'cb7') {
const cbx = format === 'cbr' ? await extractCbrMetadata(absolutePath) : await extractCb7Metadata(absolutePath);
if (cbx) {
parsed = {
title: cbx.title,
subtitle: null,
description: cbx.description,
isbn10: null,
isbn13: null,
publisher: cbx.publisher,
publishedYear: cbx.publishedYear,
language: cbx.language,
seriesName: cbx.seriesName,
seriesIndex: cbx.seriesIndex,
authors: cbx.authors,
tags: cbx.tags,
};
}
} else if (format === 'mobi' || format === 'azw3' || format === 'azw') {
const mobi = await parseMobiFile(absolutePath);
if (mobi) {
+1 -18
View File
@@ -1,23 +1,6 @@
import { extname, basename } from 'path';
const PRIMARY_FORMATS = new Set([
'epub',
'pdf',
'cbz',
'cbr',
'cb7',
'cbt',
'mobi',
'azw3',
'azw',
'fb2',
'djvu',
'djv',
'txt',
'docx',
'rtf',
'lit',
]);
const PRIMARY_FORMATS = new Set(['epub', 'pdf', 'cbz', 'cbr', 'cb7', 'mobi', 'azw3', 'azw', 'fb2', 'djvu', 'djv', 'txt', 'docx', 'rtf', 'lit']);
const COVER_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp']);
const COVER_BASENAMES = new Set(['cover', 'folder', 'thumbnail', 'artwork', 'front']);
@@ -1,6 +1,6 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
const METADATA_FORMATS = new Set(['epub', 'mobi', 'azw3', 'azw', 'cbz', 'cbr', 'pdf']);
const METADATA_FORMATS = new Set(['epub', 'mobi', 'azw3', 'azw', 'cbz', 'cbr', 'cb7', 'pdf']);
import { MetadataService } from '../metadata/metadata.service';
import { classifyFile } from './lib/classify';