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:
@ -1,7 +1,9 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listCbzImageEntries } from "../common/cbz.js";
|
||||
import { extractMetadata } from "./metadata.js";
|
||||
|
||||
describe("pdf metadata extraction", () => {
|
||||
@ -16,3 +18,21 @@ describe("pdf metadata extraction", () => {
|
||||
expect(metadata.author).toBe("Ada");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cbz metadata extraction", () => {
|
||||
it("uses the file name as title and first image as cover", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
|
||||
const file = join(dir, "Comic One.cbz");
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("002.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
||||
zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
||||
zip.writeZip(file);
|
||||
|
||||
const metadata = extractMetadata(file, dir);
|
||||
const pages = listCbzImageEntries(file);
|
||||
|
||||
expect(metadata.title).toBe("Comic One");
|
||||
expect(metadata.coverPath).toMatch(/covers\/[a-f0-9]+\.jpg$/);
|
||||
expect(pages.map((page) => page.name)).toEqual(["001.jpg", "002.jpg"]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 { listCbzImageEntries } from "../common/cbz.js";
|
||||
|
||||
export type BookMetadata = {
|
||||
title: string;
|
||||
@ -26,6 +27,9 @@ export function extractMetadata(filePath: string, storageDir: string): BookMetad
|
||||
if (extension === ".epub") {
|
||||
return extractEpubMetadata(filePath, storageDir);
|
||||
}
|
||||
if (extension === ".cbz") {
|
||||
return extractCbzMetadata(filePath, storageDir);
|
||||
}
|
||||
return extractPdfMetadata(filePath);
|
||||
}
|
||||
|
||||
@ -78,6 +82,16 @@ function extractPdfMetadata(filePath: string): BookMetadata {
|
||||
};
|
||||
}
|
||||
|
||||
function extractCbzMetadata(filePath: string, storageDir: string): BookMetadata {
|
||||
const zip = new AdmZip(filePath);
|
||||
const firstPage = listCbzImageEntries(filePath)[0];
|
||||
const coverPath = extractCover(zip, firstPage.entryName, filePath, storageDir);
|
||||
return {
|
||||
...fallbackMetadata(filePath),
|
||||
coverPath
|
||||
};
|
||||
}
|
||||
|
||||
function fallbackMetadata(filePath: string): BookMetadata {
|
||||
return {
|
||||
title: basename(filePath, extname(filePath)),
|
||||
|
||||
@ -50,7 +50,7 @@ export class ScannerService {
|
||||
}
|
||||
|
||||
const now = this.database.now();
|
||||
const format: "epub" | "pdf" = extname(filePath).toLowerCase() === ".epub" ? "epub" : "pdf";
|
||||
const format = bookFormatFromPath(filePath);
|
||||
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get();
|
||||
const values = {
|
||||
libraryId,
|
||||
@ -84,8 +84,15 @@ function* walkBooks(root: string): Generator<string> {
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const extension = extname(entry.name).toLowerCase();
|
||||
if (extension === ".epub" || extension === ".pdf") {
|
||||
if (extension === ".epub" || extension === ".pdf" || extension === ".cbz") {
|
||||
yield path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
if (extension === ".epub") return "epub";
|
||||
if (extension === ".cbz") return "cbz";
|
||||
return "pdf";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user