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

@ -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");