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:
@ -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));
|
||||
|
||||
@ -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>) {
|
||||
|
||||
Reference in New Issue
Block a user