feat(api,web): support CBZ — scan, métadonnées, lecteur d'albums

- api: cbz utilitaire commun, scanner/métadonnées (+ tests), books, schéma et migrations
- web: CbzReader, ReaderPage/locators (+ tests), types et client API
- shared: types formats

Refs: #16
This commit is contained in:
Git Agent
2026-08-23 11:52:40 +02:00
parent 4b7d4d45ce
commit e5513d81eb
18 changed files with 366 additions and 15 deletions

View File

@ -26,6 +26,19 @@ export class BooksController {
return this.books.get(Number(id));
}
@Get(":id/pages")
pages(@Param("id") id: string) {
return this.books.listCbzPages(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));
reply.header("Content-Type", result.contentType);
reply.header("Cache-Control", "private, max-age=3600");
return reply.send(result.data);
}
@Get(":id/file")
file(@Param("id") id: string, @Res() reply: FastifyReply) {
const { book, stream } = this.books.streamFile(Number(id));

View File

@ -1,7 +1,9 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { createReadStream, existsSync } from "node:fs";
import { extname } from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { BookQueryDto } from "@readabook/shared";
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
import { DatabaseService } from "../database/database.service.js";
import { books } from "../database/schema.js";
@ -66,9 +68,49 @@ export class BooksService {
return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath };
}
listCbzPages(id: number) {
const book = this.get(id);
this.assertCbzBook(book);
const pages = listCbzImageEntries(book.filePath);
return {
bookId: book.id,
pageCount: pages.length,
pages: pages.map((page, index) => ({ page: index + 1, name: page.name }))
};
}
readCbzPage(id: number, page: number) {
const book = this.get(id);
this.assertCbzBook(book);
try {
const result = 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");
}
}
count() {
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");
}
if (!existsSync(book.filePath)) {
throw new NotFoundException("Book file not found on disk");
}
}
}
function lookupMime(entryName: string): string {
const extension = extname(entryName).toLowerCase();
if (extension === ".png") return "image/png";
if (extension === ".webp") return "image/webp";
if (extension === ".gif") return "image/gif";
if (extension === ".avif") return "image/avif";
return "image/jpeg";
}
function mapBookRow(row: Record<string, unknown>) {

View File

@ -0,0 +1,46 @@
import { extname } from "node:path";
import AdmZip from "adm-zip";
export type CbzPageEntry = {
entryName: string;
name: string;
};
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]);
const MAX_CBZ_ENTRIES = 20000;
export function listCbzImageEntries(filePath: string): CbzPageEntry[] {
const zip = new AdmZip(filePath);
const entries = zip.getEntries();
if (entries.length > MAX_CBZ_ENTRIES) {
throw new Error("CBZ archive has too many entries");
}
const images = entries
.filter((entry) => !entry.isDirectory && 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" }));
if (!images.length) {
throw new Error("CBZ archive does not contain readable image pages");
}
return images;
}
export function readCbzPage(filePath: string, pageNumber: number): { entryName: string; data: Buffer } {
if (!Number.isInteger(pageNumber) || pageNumber < 1) {
throw new Error("CBZ page number must be a positive integer");
}
const zip = new AdmZip(filePath);
const pages = listCbzImageEntries(filePath);
const page = pages[pageNumber - 1];
if (!page) {
throw new Error("CBZ page not found");
}
const entry = zip.getEntry(page.entryName);
if (!entry) {
throw new Error("CBZ page not found");
}
return { entryName: entry.entryName, data: entry.getData() };
}

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')),
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz')),
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
file_size INTEGER NOT NULL,
@ -119,6 +119,78 @@ export class DatabaseService implements OnModuleDestroy {
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
`);
this.ensureBooksSupportsCbz();
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
}
private ensureBooksSupportsCbz(): 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;
this.sqlite.exec(`
PRAGMA foreign_keys = OFF;
PRAGMA legacy_alter_table = ON;
DROP TRIGGER IF EXISTS books_ai;
DROP TRIGGER IF EXISTS books_ad;
DROP TRIGGER IF EXISTS books_au;
BEGIN;
ALTER TABLE books RENAME TO books_legacy_format;
CREATE TABLE books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
title TEXT NOT NULL,
author TEXT,
description TEXT,
isbn TEXT,
language TEXT,
publisher TEXT,
published_date TEXT,
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz')),
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
file_size INTEGER NOT NULL,
file_mtime TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO books (
id, library_id, title, author, description, isbn, language, publisher, published_date,
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
)
SELECT
id, library_id, title, author, description, isbn, language, publisher, published_date,
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
FROM books_legacy_format;
DROP TABLE books_legacy_format;
COMMIT;
PRAGMA legacy_alter_table = OFF;
PRAGMA foreign_keys = ON;
CREATE UNIQUE INDEX IF NOT EXISTS books_file_path_unique ON books(file_path);
CREATE INDEX IF NOT EXISTS books_library_idx ON books(library_id);
CREATE INDEX IF NOT EXISTS books_title_idx ON books(title);
CREATE TRIGGER IF NOT EXISTS books_ai AFTER INSERT ON books BEGIN
INSERT INTO book_fts(rowid, title, author, description, isbn)
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
CREATE TRIGGER IF NOT EXISTS books_ad AFTER DELETE ON books BEGIN
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
END;
CREATE TRIGGER IF NOT EXISTS books_au AFTER UPDATE ON books BEGIN
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
INSERT INTO book_fts(rowid, title, author, description, isbn)
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
`);
}
}

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"] }).notNull(),
format: text("format", { enum: ["epub", "pdf", "cbz"] }).notNull(),
filePath: text("file_path").notNull(),
coverPath: text("cover_path"),
fileSize: integer("file_size").notNull(),

View File

@ -1,7 +1,9 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import AdmZip from "adm-zip";
import { describe, expect, it } from "vitest";
import { listCbzImageEntries } from "../common/cbz.js";
import { extractMetadata } from "./metadata.js";
describe("pdf metadata extraction", () => {
@ -16,3 +18,21 @@ describe("pdf metadata extraction", () => {
expect(metadata.author).toBe("Ada");
});
});
describe("cbz metadata extraction", () => {
it("uses the file name as title and first image as cover", () => {
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
const file = join(dir, "Comic One.cbz");
const zip = new AdmZip();
zip.addFile("002.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
zip.writeZip(file);
const metadata = extractMetadata(file, dir);
const pages = listCbzImageEntries(file);
expect(metadata.title).toBe("Comic One");
expect(metadata.coverPath).toMatch(/covers\/[a-f0-9]+\.jpg$/);
expect(pages.map((page) => page.name)).toEqual(["001.jpg", "002.jpg"]);
});
});

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 { listCbzImageEntries } from "../common/cbz.js";
export type BookMetadata = {
title: string;
@ -26,6 +27,9 @@ export function extractMetadata(filePath: string, storageDir: string): BookMetad
if (extension === ".epub") {
return extractEpubMetadata(filePath, storageDir);
}
if (extension === ".cbz") {
return extractCbzMetadata(filePath, storageDir);
}
return extractPdfMetadata(filePath);
}
@ -78,6 +82,16 @@ function extractPdfMetadata(filePath: string): BookMetadata {
};
}
function extractCbzMetadata(filePath: string, storageDir: string): BookMetadata {
const zip = new AdmZip(filePath);
const firstPage = listCbzImageEntries(filePath)[0];
const coverPath = extractCover(zip, firstPage.entryName, filePath, storageDir);
return {
...fallbackMetadata(filePath),
coverPath
};
}
function fallbackMetadata(filePath: string): BookMetadata {
return {
title: basename(filePath, extname(filePath)),

View File

@ -50,7 +50,7 @@ export class ScannerService {
}
const now = this.database.now();
const format: "epub" | "pdf" = extname(filePath).toLowerCase() === ".epub" ? "epub" : "pdf";
const format = bookFormatFromPath(filePath);
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get();
const values = {
libraryId,
@ -84,8 +84,15 @@ function* walkBooks(root: string): Generator<string> {
}
if (!entry.isFile()) continue;
const extension = extname(entry.name).toLowerCase();
if (extension === ".epub" || extension === ".pdf") {
if (extension === ".epub" || extension === ".pdf" || extension === ".cbz") {
yield path;
}
}
}
function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" {
const extension = extname(filePath).toLowerCase();
if (extension === ".epub") return "epub";
if (extension === ".cbz") return "cbz";
return "pdf";
}