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>) {