mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(reader): add CBR and CB7 comic support with cover and metadata
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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';
|
||||
|
||||
Reference in New Issue
Block a user