From 369fbb0e074bd9362f8b9dd6e914b49a611ca655 Mon Sep 17 00:00:00 2001 From: Git Agent Date: Sun, 23 Aug 2026 12:08:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(api,web):=20support=20CBR=20=E2=80=94=20ex?= =?UTF-8?q?traction=20RAR,=20scan=20et=20lecture=20d'albums?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/package.json | 1 + apps/api/src/books/books.controller.ts | 6 +-- apps/api/src/books/books.service.ts | 24 +++++---- apps/api/src/common/cbr.ts | 63 +++++++++++++++++++++++ apps/api/src/common/cbz.ts | 8 +-- apps/api/src/database/database.service.ts | 10 ++-- apps/api/src/database/schema.ts | 2 +- apps/api/src/scanner/metadata.test.ts | 8 +-- apps/api/src/scanner/metadata.ts | 20 ++++++- apps/api/src/scanner/scanner.service.ts | 7 +-- apps/web/src/api/mockData.ts | 21 +++++++- apps/web/src/components/ui.tsx | 2 +- apps/web/src/pages/ReaderPage.tsx | 15 +++--- apps/web/src/reader/locators.test.ts | 7 ++- apps/web/src/reader/locators.ts | 5 ++ apps/web/src/styles/app.css | 5 ++ packages/shared/src/index.ts | 4 +- pnpm-lock.yaml | 9 ++++ 18 files changed, 174 insertions(+), 43 deletions(-) create mode 100644 apps/api/src/common/cbr.ts diff --git a/apps/api/package.json b/apps/api/package.json index 1e3a4d3..c0cb9ca 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" diff --git a/apps/api/src/books/books.controller.ts b/apps/api/src/books/books.controller.ts index ab668fd..fe2cbe9 100644 --- a/apps/api/src/books/books.controller.ts +++ b/apps/api/src/books/books.controller.ts @@ -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); diff --git a/apps/api/src/books/books.service.ts b/apps/api/src/books/books.service.ts index 7c3295c..26d2ad7 100644 --- a/apps/api/src/books/books.service.ts +++ b/apps/api/src/books/books.service.ts @@ -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`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"); diff --git a/apps/api/src/common/cbr.ts b/apps/api/src/common/cbr.ts new file mode 100644 index 0000000..cbeacad --- /dev/null +++ b/apps/api/src/common/cbr.ts @@ -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 { + 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 }); + } +} diff --git a/apps/api/src/common/cbz.ts b/apps/api/src/common/cbz.ts index 81a173e..1575518 100644 --- a/apps/api/src/common/cbz.ts +++ b/apps/api/src/common/cbz.ts @@ -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" })); diff --git a/apps/api/src/database/database.service.ts b/apps/api/src/database/database.service.ts index 2b48697..9c26a7b 100644 --- a/apps/api/src/database/database.service.ts +++ b/apps/api/src/database/database.service.ts @@ -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, diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts index 5192369..cd04364 100644 --- a/apps/api/src/database/schema.ts +++ b/apps/api/src/database/schema.ts @@ -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(), diff --git a/apps/api/src/scanner/metadata.test.ts b/apps/api/src/scanner/metadata.test.ts index 9f2f25e..18e0b9a 100644 --- a/apps/api/src/scanner/metadata.test.ts +++ b/apps/api/src/scanner/metadata.test.ts @@ -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"); diff --git a/apps/api/src/scanner/metadata.ts b/apps/api/src/scanner/metadata.ts index 7031d96..78196cd 100644 --- a/apps/api/src/scanner/metadata.ts +++ b/apps/api/src/scanner/metadata.ts @@ -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 { 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 { + 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)), diff --git a/apps/api/src/scanner/scanner.service.ts b/apps/api/src/scanner/scanner.service.ts index cc7f9e3..90924fe 100644 --- a/apps/api/src/scanner/scanner.service.ts +++ b/apps/api/src/scanner/scanner.service.ts @@ -40,7 +40,7 @@ export class ScannerService { private async ingestFile(libraryId: number, filePath: string): Promise { 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 { } 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"; } diff --git a/apps/web/src/api/mockData.ts b/apps/web/src/api/mockData.ts index a06a39c..0db5833 100644 --- a/apps/web/src/api/mockData.ts +++ b/apps/web/src/api/mockData.ts @@ -70,13 +70,32 @@ export const mockBooks: BookDto[] = [ fileMtime: now, createdAt: now, updatedAt: now + }, + { + id: 4, + libraryId: 2, + title: "Cabinet noir", + author: "L. Rar", + description: "Archive CBR lue avec le même parcours paginé que les comics CBZ.", + isbn: null, + language: "fr", + publisher: "ReadaBook", + publishedDate: "1937", + format: "cbr", + filePath: "/library/cbr/cabinet-noir.cbr", + coverPath: null, + fileSize: 14800000, + fileMtime: now, + createdAt: now, + updatedAt: now } ]; export const mockProgress: ProgressDto[] = [ { bookId: 1, locator: "mock:chapter-3", percent: 42, updatedAt: now }, { bookId: 2, locator: "pdf:page:12", percent: 18, updatedAt: now }, - { bookId: 3, locator: "cbz:page:4", percent: 40, updatedAt: now } + { bookId: 3, locator: "cbz:page:4", percent: 40, updatedAt: now }, + { bookId: 4, locator: "cbr:page:6", percent: 60, updatedAt: now } ]; export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({ diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx index 1f8988e..03868f4 100644 --- a/apps/web/src/components/ui.tsx +++ b/apps/web/src/components/ui.tsx @@ -28,7 +28,7 @@ export function ErrorRibbon({ message }: { message?: string }) { return
{message}
; } -export function FormatPill({ format }: { format: "epub" | "pdf" | "cbz" }) { +export function FormatPill({ format }: { format: "epub" | "pdf" | "cbz" | "cbr" }) { return {format.toUpperCase()}; } diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx index 61fbb89..de88794 100644 --- a/apps/web/src/pages/ReaderPage.tsx +++ b/apps/web/src/pages/ReaderPage.tsx @@ -6,7 +6,7 @@ import { ErrorRibbon, Meter } from "../components/ui"; import { navigate } from "../router"; import { CbzReader } from "../reader/CbzReader"; import { EpubReader } from "../reader/EpubReader"; -import { parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators"; +import { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators"; import { PdfReader } from "../reader/PdfReader"; import { useReaderProgress } from "../reader/useReaderProgress"; @@ -36,7 +36,7 @@ export function ReaderPage({ bookId }: { bookId: number }) { }, [bookId]); useEffect(() => { - const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator); + const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator); if (nextPage) setPage((current) => (current === nextPage ? current : nextPage)); }, [progress]); @@ -49,12 +49,13 @@ export function ReaderPage({ bookId }: { bookId: number }) { [save] ); const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]); - const saveCbzPage = useCallback( + const saveComicPage = useCallback( (nextPage: number, pages: number) => { setPage(nextPage); - void save(`cbz:page:${nextPage}`, pdfPagePercent(nextPage, pages)); + const prefix = book?.format === "cbr" ? "cbr" : "cbz"; + void save(`${prefix}:page:${nextPage}`, pdfPagePercent(nextPage, pages)); }, - [save] + [book?.format, save] ); return ( @@ -87,8 +88,8 @@ export function ReaderPage({ bookId }: { bookId: number }) { ) : book.format === "pdf" ? ( - ) : book.format === "cbz" ? ( - + ) : book.format === "cbz" || book.format === "cbr" ? ( + ) : ( )} diff --git a/apps/web/src/reader/locators.test.ts b/apps/web/src/reader/locators.test.ts index f3ab54a..77db26c 100644 --- a/apps/web/src/reader/locators.test.ts +++ b/apps/web/src/reader/locators.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "./locators"; +import { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "./locators"; describe("reader locators", () => { it("parses valid PDF page locators", () => { @@ -16,6 +16,11 @@ describe("reader locators", () => { expect(parseCbzPageLocator("pdf:page:7")).toBeNull(); }); + it("parses CBR page locators", () => { + expect(parseCbrPageLocator("cbr:page:9")).toBe(9); + expect(parseCbrPageLocator("cbz:page:9")).toBeNull(); + }); + it("bounds PDF page percentages", () => { expect(pdfPagePercent(2, 4)).toBe(50); expect(pdfPagePercent(8, 4)).toBe(100); diff --git a/apps/web/src/reader/locators.ts b/apps/web/src/reader/locators.ts index a25314a..a8c66d8 100644 --- a/apps/web/src/reader/locators.ts +++ b/apps/web/src/reader/locators.ts @@ -8,6 +8,11 @@ export function parseCbzPageLocator(locator?: string | null): number | null { return parsePageSuffix(locator); } +export function parseCbrPageLocator(locator?: string | null): number | null { + if (!locator?.startsWith("cbr:page:")) return null; + return parsePageSuffix(locator); +} + function parsePageSuffix(locator: string): number | null { const value = Number(locator.split(":").at(-1)); return Number.isInteger(value) && value > 0 ? value : null; diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 7e85afc..4ca26f0 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -263,6 +263,11 @@ h2 { color: var(--ink); } +.format-cbr { + background: #6c4f2a; + color: var(--ink); +} + .continue-grid, .library-list, .job-list { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a8cb937..6acab3f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -94,7 +94,7 @@ export const BookSchema = z.object({ language: z.string().nullable(), publisher: z.string().nullable(), publishedDate: z.string().nullable(), - format: z.enum(["epub", "pdf", "cbz"]), + format: z.enum(["epub", "pdf", "cbz", "cbr"]), filePath: z.string(), coverPath: z.string().nullable(), fileSize: z.number().int().nonnegative(), @@ -106,7 +106,7 @@ export type BookDto = z.infer; export const BookQuerySchema = z.object({ q: z.string().optional(), - format: z.enum(["epub", "pdf", "cbz"]).optional(), + format: z.enum(["epub", "pdf", "cbz", "cbr"]).optional(), libraryId: z.coerce.number().int().positive().optional(), limit: z.coerce.number().int().min(1).max(100).default(50), offset: z.coerce.number().int().min(0).default(0) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07a408f..32882f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: mime-types: specifier: ^2.1.35 version: 2.1.35 + node-unrar-js: + specifier: ^2.0.2 + version: 2.0.2 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -1202,6 +1205,10 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true + node-unrar-js@2.0.2: + resolution: {integrity: sha512-hLNmoJzqaKJnod8yiTVGe9hnlNRHotUi0CreSv/8HtfRi/3JnRC8DvsmKfeGGguRjTEulhZK6zXX5PXoVuDZ2w==} + engines: {node: '>=10.0.0'} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -2352,6 +2359,8 @@ snapshots: node-gyp-build@4.8.4: {} + node-unrar-js@2.0.2: {} + on-exit-leak-free@2.1.2: {} once@1.4.0: