merge: feat/17-cbr-support dans develop (support CBR)
This commit is contained in:
@ -26,6 +26,7 @@
|
|||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"jose": "^5.9.6",
|
"jose": "^5.9.6",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
"node-unrar-js": "^2.0.2",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
|
|||||||
@ -28,12 +28,12 @@ export class BooksController {
|
|||||||
|
|
||||||
@Get(":id/pages")
|
@Get(":id/pages")
|
||||||
pages(@Param("id") id: string) {
|
pages(@Param("id") id: string) {
|
||||||
return this.books.listCbzPages(Number(id));
|
return this.books.listComicPages(Number(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/pages/:page")
|
@Get(":id/pages/:page")
|
||||||
page(@Param("id") id: string, @Param("page") page: string, @Res() reply: FastifyReply) {
|
async page(@Param("id") id: string, @Param("page") page: string, @Res() reply: FastifyReply) {
|
||||||
const result = this.books.readCbzPage(Number(id), Number(page));
|
const result = await this.books.readComicPage(Number(id), Number(page));
|
||||||
reply.header("Content-Type", result.contentType);
|
reply.header("Content-Type", result.contentType);
|
||||||
reply.header("Cache-Control", "private, max-age=3600");
|
reply.header("Cache-Control", "private, max-age=3600");
|
||||||
return reply.send(result.data);
|
return reply.send(result.data);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { createReadStream, existsSync } from "node:fs";
|
|||||||
import { extname } from "node:path";
|
import { extname } from "node:path";
|
||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { BookQueryDto } from "@readabook/shared";
|
import { BookQueryDto } from "@readabook/shared";
|
||||||
|
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
|
||||||
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
|
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
|
||||||
import { DatabaseService } from "../database/database.service.js";
|
import { DatabaseService } from "../database/database.service.js";
|
||||||
import { books } from "../database/schema.js";
|
import { books } from "../database/schema.js";
|
||||||
@ -68,10 +69,10 @@ export class BooksService {
|
|||||||
return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath };
|
return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
listCbzPages(id: number) {
|
async listComicPages(id: number) {
|
||||||
const book = this.get(id);
|
const book = this.get(id);
|
||||||
this.assertCbzBook(book);
|
this.assertComicArchiveBook(book);
|
||||||
const pages = listCbzImageEntries(book.filePath);
|
const pages = book.format === "cbr" ? await listCbrImageEntries(book.filePath) : listCbzImageEntries(book.filePath);
|
||||||
return {
|
return {
|
||||||
bookId: book.id,
|
bookId: book.id,
|
||||||
pageCount: pages.length,
|
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);
|
const book = this.get(id);
|
||||||
this.assertCbzBook(book);
|
this.assertComicArchiveBook(book);
|
||||||
try {
|
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 };
|
return { book, page, contentType: lookupMime(result.entryName), data: result.data };
|
||||||
} catch (error) {
|
} 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;
|
return this.database.db.select({ count: sql<number>`count(*)` }).from(books).get()?.count ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertCbzBook(book: typeof books.$inferSelect): void {
|
private assertComicArchiveBook(book: typeof books.$inferSelect): void {
|
||||||
if (book.format !== "cbz") {
|
if (book.format !== "cbz" && book.format !== "cbr") {
|
||||||
throw new BadRequestException("Book is not a CBZ archive");
|
throw new BadRequestException("Book is not a comic archive");
|
||||||
}
|
}
|
||||||
if (!existsSync(book.filePath)) {
|
if (!existsSync(book.filePath)) {
|
||||||
throw new NotFoundException("Book file not found on disk");
|
throw new NotFoundException("Book file not found on disk");
|
||||||
|
|||||||
63
apps/api/src/common/cbr.ts
Normal file
63
apps/api/src/common/cbr.ts
Normal file
@ -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<CbzPageEntry[]> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,18 +6,18 @@ export type CbzPageEntry = {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]);
|
export const COMIC_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]);
|
||||||
const MAX_CBZ_ENTRIES = 20000;
|
export const MAX_COMIC_ARCHIVE_ENTRIES = 20000;
|
||||||
|
|
||||||
export function listCbzImageEntries(filePath: string): CbzPageEntry[] {
|
export function listCbzImageEntries(filePath: string): CbzPageEntry[] {
|
||||||
const zip = new AdmZip(filePath);
|
const zip = new AdmZip(filePath);
|
||||||
const entries = zip.getEntries();
|
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");
|
throw new Error("CBZ archive has too many entries");
|
||||||
}
|
}
|
||||||
|
|
||||||
const images = 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 }))
|
.map((entry) => ({ entryName: entry.entryName, name: entry.name }))
|
||||||
.sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true, sensitivity: "base" }));
|
.sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true, sensitivity: "base" }));
|
||||||
|
|
||||||
|
|||||||
@ -59,7 +59,7 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
language TEXT,
|
language TEXT,
|
||||||
publisher TEXT,
|
publisher TEXT,
|
||||||
published_date 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,
|
file_path TEXT NOT NULL UNIQUE,
|
||||||
cover_path TEXT,
|
cover_path TEXT,
|
||||||
file_size INTEGER NOT NULL,
|
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);
|
VALUES (new.id, new.title, new.author, new.description, new.isbn);
|
||||||
END;
|
END;
|
||||||
`);
|
`);
|
||||||
this.ensureBooksSupportsCbz();
|
this.ensureBooksSupportsComicArchives();
|
||||||
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
|
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureBooksSupportsCbz(): void {
|
private ensureBooksSupportsComicArchives(): void {
|
||||||
const table = this.sqlite
|
const table = this.sqlite
|
||||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'books'")
|
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'books'")
|
||||||
.get() as { sql?: string } | undefined;
|
.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(`
|
this.sqlite.exec(`
|
||||||
PRAGMA foreign_keys = OFF;
|
PRAGMA foreign_keys = OFF;
|
||||||
@ -149,7 +149,7 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
language TEXT,
|
language TEXT,
|
||||||
publisher TEXT,
|
publisher TEXT,
|
||||||
published_date 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,
|
file_path TEXT NOT NULL UNIQUE,
|
||||||
cover_path TEXT,
|
cover_path TEXT,
|
||||||
file_size INTEGER NOT NULL,
|
file_size INTEGER NOT NULL,
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export const books = sqliteTable(
|
|||||||
language: text("language"),
|
language: text("language"),
|
||||||
publisher: text("publisher"),
|
publisher: text("publisher"),
|
||||||
publishedDate: text("published_date"),
|
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(),
|
filePath: text("file_path").notNull(),
|
||||||
coverPath: text("cover_path"),
|
coverPath: text("cover_path"),
|
||||||
fileSize: integer("file_size").notNull(),
|
fileSize: integer("file_size").notNull(),
|
||||||
|
|||||||
@ -7,12 +7,12 @@ import { listCbzImageEntries } from "../common/cbz.js";
|
|||||||
import { extractMetadata } from "./metadata.js";
|
import { extractMetadata } from "./metadata.js";
|
||||||
|
|
||||||
describe("pdf metadata extraction", () => {
|
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 dir = mkdtempSync(join(tmpdir(), "readabook-"));
|
||||||
const file = join(dir, "Example.pdf");
|
const file = join(dir, "Example.pdf");
|
||||||
writeFileSync(file, "%PDF-1.4\n1 0 obj << /Title (My Book) /Author (Ada) >> endobj");
|
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.title).toBe("My Book");
|
||||||
expect(metadata.author).toBe("Ada");
|
expect(metadata.author).toBe("Ada");
|
||||||
@ -20,7 +20,7 @@ describe("pdf metadata extraction", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("cbz 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 dir = mkdtempSync(join(tmpdir(), "readabook-"));
|
||||||
const file = join(dir, "Comic One.cbz");
|
const file = join(dir, "Comic One.cbz");
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
@ -28,7 +28,7 @@ describe("cbz metadata extraction", () => {
|
|||||||
zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
||||||
zip.writeZip(file);
|
zip.writeZip(file);
|
||||||
|
|
||||||
const metadata = extractMetadata(file, dir);
|
const metadata = await extractMetadata(file, dir);
|
||||||
const pages = listCbzImageEntries(file);
|
const pages = listCbzImageEntries(file);
|
||||||
|
|
||||||
expect(metadata.title).toBe("Comic One");
|
expect(metadata.title).toBe("Comic One");
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|||||||
import { basename, dirname, extname, join } from "node:path";
|
import { basename, dirname, extname, join } from "node:path";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { XMLParser } from "fast-xml-parser";
|
import { XMLParser } from "fast-xml-parser";
|
||||||
|
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
|
||||||
import { listCbzImageEntries } from "../common/cbz.js";
|
import { listCbzImageEntries } from "../common/cbz.js";
|
||||||
|
|
||||||
export type BookMetadata = {
|
export type BookMetadata = {
|
||||||
@ -22,7 +23,7 @@ const xmlParser = new XMLParser({
|
|||||||
textNodeName: "#text"
|
textNodeName: "#text"
|
||||||
});
|
});
|
||||||
|
|
||||||
export function extractMetadata(filePath: string, storageDir: string): BookMetadata {
|
export async function extractMetadata(filePath: string, storageDir: string): Promise<BookMetadata> {
|
||||||
const extension = extname(filePath).toLowerCase();
|
const extension = extname(filePath).toLowerCase();
|
||||||
if (extension === ".epub") {
|
if (extension === ".epub") {
|
||||||
return extractEpubMetadata(filePath, storageDir);
|
return extractEpubMetadata(filePath, storageDir);
|
||||||
@ -30,6 +31,9 @@ export function extractMetadata(filePath: string, storageDir: string): BookMetad
|
|||||||
if (extension === ".cbz") {
|
if (extension === ".cbz") {
|
||||||
return extractCbzMetadata(filePath, storageDir);
|
return extractCbzMetadata(filePath, storageDir);
|
||||||
}
|
}
|
||||||
|
if (extension === ".cbr") {
|
||||||
|
return extractCbrMetadata(filePath, storageDir);
|
||||||
|
}
|
||||||
return extractPdfMetadata(filePath);
|
return extractPdfMetadata(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,6 +96,20 @@ function extractCbzMetadata(filePath: string, storageDir: string): BookMetadata
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function extractCbrMetadata(filePath: string, storageDir: string): Promise<BookMetadata> {
|
||||||
|
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 {
|
function fallbackMetadata(filePath: string): BookMetadata {
|
||||||
return {
|
return {
|
||||||
title: basename(filePath, extname(filePath)),
|
title: basename(filePath, extname(filePath)),
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export class ScannerService {
|
|||||||
|
|
||||||
private async ingestFile(libraryId: number, filePath: string): Promise<void> {
|
private async ingestFile(libraryId: number, filePath: string): Promise<void> {
|
||||||
const stats = statSync(filePath);
|
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) {
|
if (this.database.config.openLibraryEnabled) {
|
||||||
try {
|
try {
|
||||||
metadata = { ...metadata, ...(await this.openLibrary.enrich(metadata)) };
|
metadata = { ...metadata, ...(await this.openLibrary.enrich(metadata)) };
|
||||||
@ -84,15 +84,16 @@ function* walkBooks(root: string): Generator<string> {
|
|||||||
}
|
}
|
||||||
if (!entry.isFile()) continue;
|
if (!entry.isFile()) continue;
|
||||||
const extension = extname(entry.name).toLowerCase();
|
const extension = extname(entry.name).toLowerCase();
|
||||||
if (extension === ".epub" || extension === ".pdf" || extension === ".cbz") {
|
if (extension === ".epub" || extension === ".pdf" || extension === ".cbz" || extension === ".cbr") {
|
||||||
yield path;
|
yield path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" {
|
function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" | "cbr" {
|
||||||
const extension = extname(filePath).toLowerCase();
|
const extension = extname(filePath).toLowerCase();
|
||||||
if (extension === ".epub") return "epub";
|
if (extension === ".epub") return "epub";
|
||||||
if (extension === ".cbz") return "cbz";
|
if (extension === ".cbz") return "cbz";
|
||||||
|
if (extension === ".cbr") return "cbr";
|
||||||
return "pdf";
|
return "pdf";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,13 +70,32 @@ export const mockBooks: BookDto[] = [
|
|||||||
fileMtime: now,
|
fileMtime: now,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: 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[] = [
|
export const mockProgress: ProgressDto[] = [
|
||||||
{ bookId: 1, locator: "mock:chapter-3", percent: 42, updatedAt: now },
|
{ bookId: 1, locator: "mock:chapter-3", percent: 42, updatedAt: now },
|
||||||
{ bookId: 2, locator: "pdf:page:12", percent: 18, 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) => ({
|
export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export function ErrorRibbon({ message }: { message?: string }) {
|
|||||||
return <div className="error-ribbon">{message}</div>;
|
return <div className="error-ribbon">{message}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormatPill({ format }: { format: "epub" | "pdf" | "cbz" }) {
|
export function FormatPill({ format }: { format: "epub" | "pdf" | "cbz" | "cbr" }) {
|
||||||
return <span className={`format-pill format-${format}`}>{format.toUpperCase()}</span>;
|
return <span className={`format-pill format-${format}`}>{format.toUpperCase()}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { ErrorRibbon, Meter } from "../components/ui";
|
|||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { CbzReader } from "../reader/CbzReader";
|
import { CbzReader } from "../reader/CbzReader";
|
||||||
import { EpubReader } from "../reader/EpubReader";
|
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 { PdfReader } from "../reader/PdfReader";
|
||||||
import { useReaderProgress } from "../reader/useReaderProgress";
|
import { useReaderProgress } from "../reader/useReaderProgress";
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
}, [bookId]);
|
}, [bookId]);
|
||||||
|
|
||||||
useEffect(() => {
|
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));
|
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
||||||
}, [progress]);
|
}, [progress]);
|
||||||
|
|
||||||
@ -49,12 +49,13 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
[save]
|
[save]
|
||||||
);
|
);
|
||||||
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
|
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
|
||||||
const saveCbzPage = useCallback(
|
const saveComicPage = useCallback(
|
||||||
(nextPage: number, pages: number) => {
|
(nextPage: number, pages: number) => {
|
||||||
setPage(nextPage);
|
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 (
|
return (
|
||||||
@ -87,8 +88,8 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
</div>
|
</div>
|
||||||
) : book.format === "pdf" ? (
|
) : book.format === "pdf" ? (
|
||||||
<PdfReader url={fileUrl} page={page} onPageCommit={savePdfPage} />
|
<PdfReader url={fileUrl} page={page} onPageCommit={savePdfPage} />
|
||||||
) : book.format === "cbz" ? (
|
) : book.format === "cbz" || book.format === "cbr" ? (
|
||||||
<CbzReader bookId={book.id} page={page} onPageCommit={saveCbzPage} />
|
<CbzReader bookId={book.id} page={page} onPageCommit={saveComicPage} />
|
||||||
) : (
|
) : (
|
||||||
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} />
|
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "./locators";
|
import { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "./locators";
|
||||||
|
|
||||||
describe("reader locators", () => {
|
describe("reader locators", () => {
|
||||||
it("parses valid PDF page locators", () => {
|
it("parses valid PDF page locators", () => {
|
||||||
@ -16,6 +16,11 @@ describe("reader locators", () => {
|
|||||||
expect(parseCbzPageLocator("pdf:page:7")).toBeNull();
|
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", () => {
|
it("bounds PDF page percentages", () => {
|
||||||
expect(pdfPagePercent(2, 4)).toBe(50);
|
expect(pdfPagePercent(2, 4)).toBe(50);
|
||||||
expect(pdfPagePercent(8, 4)).toBe(100);
|
expect(pdfPagePercent(8, 4)).toBe(100);
|
||||||
|
|||||||
@ -8,6 +8,11 @@ export function parseCbzPageLocator(locator?: string | null): number | null {
|
|||||||
return parsePageSuffix(locator);
|
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 {
|
function parsePageSuffix(locator: string): number | null {
|
||||||
const value = Number(locator.split(":").at(-1));
|
const value = Number(locator.split(":").at(-1));
|
||||||
return Number.isInteger(value) && value > 0 ? value : null;
|
return Number.isInteger(value) && value > 0 ? value : null;
|
||||||
|
|||||||
@ -263,6 +263,11 @@ h2 {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.format-cbr {
|
||||||
|
background: #6c4f2a;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
.continue-grid,
|
.continue-grid,
|
||||||
.library-list,
|
.library-list,
|
||||||
.job-list {
|
.job-list {
|
||||||
|
|||||||
@ -94,7 +94,7 @@ export const BookSchema = z.object({
|
|||||||
language: z.string().nullable(),
|
language: z.string().nullable(),
|
||||||
publisher: z.string().nullable(),
|
publisher: z.string().nullable(),
|
||||||
publishedDate: z.string().nullable(),
|
publishedDate: z.string().nullable(),
|
||||||
format: z.enum(["epub", "pdf", "cbz"]),
|
format: z.enum(["epub", "pdf", "cbz", "cbr"]),
|
||||||
filePath: z.string(),
|
filePath: z.string(),
|
||||||
coverPath: z.string().nullable(),
|
coverPath: z.string().nullable(),
|
||||||
fileSize: z.number().int().nonnegative(),
|
fileSize: z.number().int().nonnegative(),
|
||||||
@ -106,7 +106,7 @@ export type BookDto = z.infer<typeof BookSchema>;
|
|||||||
|
|
||||||
export const BookQuerySchema = z.object({
|
export const BookQuerySchema = z.object({
|
||||||
q: z.string().optional(),
|
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(),
|
libraryId: z.coerce.number().int().positive().optional(),
|
||||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||||
offset: z.coerce.number().int().min(0).default(0)
|
offset: z.coerce.number().int().min(0).default(0)
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -59,6 +59,9 @@ importers:
|
|||||||
mime-types:
|
mime-types:
|
||||||
specifier: ^2.1.35
|
specifier: ^2.1.35
|
||||||
version: 2.1.35
|
version: 2.1.35
|
||||||
|
node-unrar-js:
|
||||||
|
specifier: ^2.0.2
|
||||||
|
version: 2.0.2
|
||||||
reflect-metadata:
|
reflect-metadata:
|
||||||
specifier: ^0.2.2
|
specifier: ^0.2.2
|
||||||
version: 0.2.2
|
version: 0.2.2
|
||||||
@ -1202,6 +1205,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||||
hasBin: true
|
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:
|
on-exit-leak-free@2.1.2:
|
||||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@ -2352,6 +2359,8 @@ snapshots:
|
|||||||
|
|
||||||
node-gyp-build@4.8.4: {}
|
node-gyp-build@4.8.4: {}
|
||||||
|
|
||||||
|
node-unrar-js@2.0.2: {}
|
||||||
|
|
||||||
on-exit-leak-free@2.1.2: {}
|
on-exit-leak-free@2.1.2: {}
|
||||||
|
|
||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user