feat(api,web): support CBR — extraction RAR, scan et lecture d'albums

- api: cbr utilitaire (extraction RAR), scanner/métadonnées (+ tests), books, schéma
- web: ReaderPage/locators (+ tests), mockData, types partagés
- deps: pnpm-lock

Refs: #17
This commit is contained in:
Git Agent
2026-08-23 12:08:05 +02:00
parent de29850c31
commit 369fbb0e07
18 changed files with 174 additions and 43 deletions

View File

@ -26,6 +26,7 @@
"fastify": "^5.2.1",
"jose": "^5.9.6",
"mime-types": "^2.1.35",
"node-unrar-js": "^2.0.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^3.24.2"

View File

@ -28,12 +28,12 @@ export class BooksController {
@Get(":id/pages")
pages(@Param("id") id: string) {
return this.books.listCbzPages(Number(id));
return this.books.listComicPages(Number(id));
}
@Get(":id/pages/:page")
page(@Param("id") id: string, @Param("page") page: string, @Res() reply: FastifyReply) {
const result = this.books.readCbzPage(Number(id), Number(page));
async page(@Param("id") id: string, @Param("page") page: string, @Res() reply: FastifyReply) {
const result = await this.books.readComicPage(Number(id), Number(page));
reply.header("Content-Type", result.contentType);
reply.header("Cache-Control", "private, max-age=3600");
return reply.send(result.data);

View File

@ -3,6 +3,7 @@ import { createReadStream, existsSync } from "node:fs";
import { extname } from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { BookQueryDto } from "@readabook/shared";
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
import { DatabaseService } from "../database/database.service.js";
import { books } from "../database/schema.js";
@ -68,10 +69,10 @@ export class BooksService {
return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath };
}
listCbzPages(id: number) {
async listComicPages(id: number) {
const book = this.get(id);
this.assertCbzBook(book);
const pages = listCbzImageEntries(book.filePath);
this.assertComicArchiveBook(book);
const pages = book.format === "cbr" ? await listCbrImageEntries(book.filePath) : listCbzImageEntries(book.filePath);
return {
bookId: book.id,
pageCount: pages.length,
@ -79,14 +80,17 @@ export class BooksService {
};
}
readCbzPage(id: number, page: number) {
async readComicPage(id: number, page: number) {
const book = this.get(id);
this.assertCbzBook(book);
this.assertComicArchiveBook(book);
try {
const result = readCbzPage(book.filePath, page);
const result =
book.format === "cbr"
? await readCbrPage(book.filePath, page, this.database.config.storageDir)
: readCbzPage(book.filePath, page);
return { book, page, contentType: lookupMime(result.entryName), data: result.data };
} catch (error) {
throw new NotFoundException(error instanceof Error ? error.message : "CBZ page not found");
throw new NotFoundException(error instanceof Error ? error.message : "Comic page not found");
}
}
@ -94,9 +98,9 @@ export class BooksService {
return this.database.db.select({ count: sql<number>`count(*)` }).from(books).get()?.count ?? 0;
}
private assertCbzBook(book: typeof books.$inferSelect): void {
if (book.format !== "cbz") {
throw new BadRequestException("Book is not a CBZ archive");
private assertComicArchiveBook(book: typeof books.$inferSelect): void {
if (book.format !== "cbz" && book.format !== "cbr") {
throw new BadRequestException("Book is not a comic archive");
}
if (!existsSync(book.filePath)) {
throw new NotFoundException("Book file not found on disk");

View File

@ -0,0 +1,63 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { extname, join } from "node:path";
import { createExtractorFromFile } from "node-unrar-js";
import { COMIC_IMAGE_EXTENSIONS, MAX_COMIC_ARCHIVE_ENTRIES } from "./cbz.js";
import type { CbzPageEntry } from "./cbz.js";
export async function listCbrImageEntries(filePath: string): Promise<CbzPageEntry[]> {
const extractor = await createExtractorFromFile({ filepath: filePath });
const list = extractor.getFileList();
if (list.arcHeader.flags.volume) {
throw new Error("Multi-volume CBR archives are not supported");
}
if (list.arcHeader.flags.headerEncrypted) {
throw new Error("Encrypted CBR archives are not supported");
}
const headers = [...list.fileHeaders];
if (headers.length > MAX_COMIC_ARCHIVE_ENTRIES) {
throw new Error("CBR archive has too many entries");
}
const images = headers
.filter((header) => !header.flags.directory && !header.flags.encrypted)
.filter((header) => COMIC_IMAGE_EXTENSIONS.has(extname(header.name).toLowerCase()))
.map((header) => ({ entryName: header.name, name: header.name.split(/[\\/]/).pop() ?? header.name }))
.sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true, sensitivity: "base" }));
if (!images.length) {
throw new Error("CBR archive does not contain readable image pages");
}
return images;
}
export async function readCbrPage(
filePath: string,
pageNumber: number,
storageDir: string
): Promise<{ entryName: string; data: Buffer }> {
if (!Number.isInteger(pageNumber) || pageNumber < 1) {
throw new Error("CBR page number must be a positive integer");
}
const pages = await listCbrImageEntries(filePath);
const page = pages[pageNumber - 1];
if (!page) {
throw new Error("CBR page not found");
}
const tempDir = mkdtempSync(join(storageDir, "cbr-page-"));
const safeName = `page${extname(page.entryName).toLowerCase() || ".jpg"}`;
try {
const extractor = await createExtractorFromFile({
filepath: filePath,
targetPath: tempDir,
filenameTransform: () => safeName
});
const extracted = extractor.extract({ files: [page.entryName] });
[...extracted.files];
return { entryName: page.entryName, data: readFileSync(join(tempDir, safeName)) };
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}

View File

@ -6,18 +6,18 @@ export type CbzPageEntry = {
name: string;
};
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]);
const MAX_CBZ_ENTRIES = 20000;
export const COMIC_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]);
export const MAX_COMIC_ARCHIVE_ENTRIES = 20000;
export function listCbzImageEntries(filePath: string): CbzPageEntry[] {
const zip = new AdmZip(filePath);
const entries = zip.getEntries();
if (entries.length > MAX_CBZ_ENTRIES) {
if (entries.length > MAX_COMIC_ARCHIVE_ENTRIES) {
throw new Error("CBZ archive has too many entries");
}
const images = entries
.filter((entry) => !entry.isDirectory && IMAGE_EXTENSIONS.has(extname(entry.entryName).toLowerCase()))
.filter((entry) => !entry.isDirectory && COMIC_IMAGE_EXTENSIONS.has(extname(entry.entryName).toLowerCase()))
.map((entry) => ({ entryName: entry.entryName, name: entry.name }))
.sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true, sensitivity: "base" }));

View File

@ -59,7 +59,7 @@ export class DatabaseService implements OnModuleDestroy {
language TEXT,
publisher TEXT,
published_date TEXT,
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz')),
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
file_size INTEGER NOT NULL,
@ -119,15 +119,15 @@ export class DatabaseService implements OnModuleDestroy {
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
`);
this.ensureBooksSupportsCbz();
this.ensureBooksSupportsComicArchives();
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
}
private ensureBooksSupportsCbz(): void {
private ensureBooksSupportsComicArchives(): void {
const table = this.sqlite
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'books'")
.get() as { sql?: string } | undefined;
if (!table?.sql || table.sql.includes("'cbz'")) return;
if (!table?.sql || (table.sql.includes("'cbz'") && table.sql.includes("'cbr'"))) return;
this.sqlite.exec(`
PRAGMA foreign_keys = OFF;
@ -149,7 +149,7 @@ export class DatabaseService implements OnModuleDestroy {
language TEXT,
publisher TEXT,
published_date TEXT,
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz')),
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
file_size INTEGER NOT NULL,

View File

@ -37,7 +37,7 @@ export const books = sqliteTable(
language: text("language"),
publisher: text("publisher"),
publishedDate: text("published_date"),
format: text("format", { enum: ["epub", "pdf", "cbz"] }).notNull(),
format: text("format", { enum: ["epub", "pdf", "cbz", "cbr"] }).notNull(),
filePath: text("file_path").notNull(),
coverPath: text("cover_path"),
fileSize: integer("file_size").notNull(),

View File

@ -7,12 +7,12 @@ import { listCbzImageEntries } from "../common/cbz.js";
import { extractMetadata } from "./metadata.js";
describe("pdf metadata extraction", () => {
it("falls back to file name and reads simple PDF info fields", () => {
it("falls back to file name and reads simple PDF info fields", async () => {
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
const file = join(dir, "Example.pdf");
writeFileSync(file, "%PDF-1.4\n1 0 obj << /Title (My Book) /Author (Ada) >> endobj");
const metadata = extractMetadata(file, dir);
const metadata = await extractMetadata(file, dir);
expect(metadata.title).toBe("My Book");
expect(metadata.author).toBe("Ada");
@ -20,7 +20,7 @@ describe("pdf metadata extraction", () => {
});
describe("cbz metadata extraction", () => {
it("uses the file name as title and first image as cover", () => {
it("uses the file name as title and first image as cover", async () => {
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
const file = join(dir, "Comic One.cbz");
const zip = new AdmZip();
@ -28,7 +28,7 @@ describe("cbz metadata extraction", () => {
zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
zip.writeZip(file);
const metadata = extractMetadata(file, dir);
const metadata = await extractMetadata(file, dir);
const pages = listCbzImageEntries(file);
expect(metadata.title).toBe("Comic One");

View File

@ -3,6 +3,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, extname, join } from "node:path";
import AdmZip from "adm-zip";
import { XMLParser } from "fast-xml-parser";
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
import { listCbzImageEntries } from "../common/cbz.js";
export type BookMetadata = {
@ -22,7 +23,7 @@ const xmlParser = new XMLParser({
textNodeName: "#text"
});
export function extractMetadata(filePath: string, storageDir: string): BookMetadata {
export async function extractMetadata(filePath: string, storageDir: string): Promise<BookMetadata> {
const extension = extname(filePath).toLowerCase();
if (extension === ".epub") {
return extractEpubMetadata(filePath, storageDir);
@ -30,6 +31,9 @@ export function extractMetadata(filePath: string, storageDir: string): BookMetad
if (extension === ".cbz") {
return extractCbzMetadata(filePath, storageDir);
}
if (extension === ".cbr") {
return extractCbrMetadata(filePath, storageDir);
}
return extractPdfMetadata(filePath);
}
@ -92,6 +96,20 @@ function extractCbzMetadata(filePath: string, storageDir: string): BookMetadata
};
}
async function extractCbrMetadata(filePath: string, storageDir: string): Promise<BookMetadata> {
const firstPage = (await listCbrImageEntries(filePath))[0];
const page = await readCbrPage(filePath, 1, storageDir);
const extension = extname(firstPage.entryName) || ".jpg";
const hash = createHash("sha256").update(filePath).digest("hex").slice(0, 24);
const target = join(storageDir, "covers", `${hash}${extension}`);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, page.data);
return {
...fallbackMetadata(filePath),
coverPath: target
};
}
function fallbackMetadata(filePath: string): BookMetadata {
return {
title: basename(filePath, extname(filePath)),

View File

@ -40,7 +40,7 @@ export class ScannerService {
private async ingestFile(libraryId: number, filePath: string): Promise<void> {
const stats = statSync(filePath);
let metadata = extractMetadata(filePath, this.database.config.storageDir);
let metadata = await extractMetadata(filePath, this.database.config.storageDir);
if (this.database.config.openLibraryEnabled) {
try {
metadata = { ...metadata, ...(await this.openLibrary.enrich(metadata)) };
@ -84,15 +84,16 @@ function* walkBooks(root: string): Generator<string> {
}
if (!entry.isFile()) continue;
const extension = extname(entry.name).toLowerCase();
if (extension === ".epub" || extension === ".pdf" || extension === ".cbz") {
if (extension === ".epub" || extension === ".pdf" || extension === ".cbz" || extension === ".cbr") {
yield path;
}
}
}
function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" {
function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" | "cbr" {
const extension = extname(filePath).toLowerCase();
if (extension === ".epub") return "epub";
if (extension === ".cbz") return "cbz";
if (extension === ".cbr") return "cbr";
return "pdf";
}