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:
@ -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");
|
||||
|
||||
@ -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<BookMetadata> {
|
||||
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<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 {
|
||||
return {
|
||||
title: basename(filePath, extname(filePath)),
|
||||
|
||||
@ -40,7 +40,7 @@ export class ScannerService {
|
||||
|
||||
private async ingestFile(libraryId: number, filePath: string): Promise<void> {
|
||||
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<string> {
|
||||
}
|
||||
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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user