merge: feature/metadata-series-reader-batch dans develop (séries/volumes, providers ComicVine/MangaDex, correctifs lecteur PDF/CBZ)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -3,10 +3,11 @@ import { AuthModule } from "../auth/auth.module.js";
|
||||
import { DatabaseModule } from "../database/database.module.js";
|
||||
import { BooksController } from "./books.controller.js";
|
||||
import { BooksService } from "./books.service.js";
|
||||
import { SeriesController } from "./series.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, DatabaseModule],
|
||||
controllers: [BooksController],
|
||||
controllers: [BooksController, SeriesController],
|
||||
providers: [BooksService],
|
||||
exports: [BooksService]
|
||||
})
|
||||
|
||||
180
apps/api/src/books/books.service.test.ts
Normal file
180
apps/api/src/books/books.service.test.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { books, libraries, series } from "../database/schema.js";
|
||||
import { BooksService } from "./books.service.js";
|
||||
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const previousStorageDir = process.env.STORAGE_DIR;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
process.env.DATABASE_PATH = previousDatabasePath;
|
||||
process.env.STORAGE_DIR = previousStorageDir;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("BooksService", () => {
|
||||
it.runIf(canLoadBetterSqlite())("exposes metadata status and parsed provenance on book API rows", () => {
|
||||
const database = createDatabase();
|
||||
const service = new BooksService(database);
|
||||
const now = database.now();
|
||||
const library = database.db
|
||||
.insert(libraries)
|
||||
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
|
||||
.returning()
|
||||
.get();
|
||||
const daredevil = database.db
|
||||
.insert(series)
|
||||
.values({
|
||||
title: "Daredevil",
|
||||
normalizedTitle: "daredevil",
|
||||
description: "Collection Daredevil",
|
||||
publisher: "Marvel",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
const book = database.db
|
||||
.insert(books)
|
||||
.values({
|
||||
libraryId: library.id,
|
||||
seriesId: daredevil.id,
|
||||
title: "Daredevil",
|
||||
author: "Roy Thomas",
|
||||
description: "Daredevil affronte une nouvelle menace.",
|
||||
isbn: "9782809476255",
|
||||
isbn13: "9782809476255",
|
||||
identifiersJson: null,
|
||||
localMetadataJson: null,
|
||||
language: "fre",
|
||||
publisher: "Panini comics",
|
||||
publishedDate: "0101-01-01T00:00:00+00:00",
|
||||
volumeNumber: 1,
|
||||
volumeLabel: "001",
|
||||
format: "cbz",
|
||||
filePath: "/library/Daredevil.cbz",
|
||||
coverPath: "/storage/covers/daredevil.jpg",
|
||||
metadataStatus: "enriched",
|
||||
metadataProvenanceJson: JSON.stringify({ title: "local", author: "bnf", coverPath: "openlibrary" }),
|
||||
scanStatus: "succeeded",
|
||||
enrichmentStatus: "succeeded",
|
||||
fileSize: 42,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
expect(service.get(book.id)).toMatchObject({
|
||||
publishedDate: null,
|
||||
seriesId: daredevil.id,
|
||||
volumeNumber: 1,
|
||||
volumeLabel: "001",
|
||||
series: { id: daredevil.id, title: "Daredevil", normalizedTitle: "daredevil" },
|
||||
metadataStatus: "enriched",
|
||||
metadataProvenance: { title: "local", author: "bnf", coverPath: "openlibrary" }
|
||||
});
|
||||
expect(service.list({ limit: 50, offset: 0 })[0]).toMatchObject({
|
||||
publishedDate: null,
|
||||
seriesId: daredevil.id,
|
||||
volumeNumber: 1,
|
||||
volumeLabel: "001",
|
||||
series: { id: daredevil.id, title: "Daredevil", normalizedTitle: "daredevil" },
|
||||
metadataStatus: "enriched",
|
||||
metadataProvenance: { title: "local", author: "bnf", coverPath: "openlibrary" }
|
||||
});
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("lists a series with distinct books sharing the same volume", () => {
|
||||
const database = createDatabase();
|
||||
const service = new BooksService(database);
|
||||
const now = database.now();
|
||||
const library = database.db
|
||||
.insert(libraries)
|
||||
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
|
||||
.returning()
|
||||
.get();
|
||||
const soloLeveling = database.db
|
||||
.insert(series)
|
||||
.values({
|
||||
title: "Solo Leveling",
|
||||
normalizedTitle: "solo leveling",
|
||||
description: null,
|
||||
publisher: null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
for (const filePath of ["/library/Solo Leveling T03.cbz", "/library/Solo Leveling 003.cbz"]) {
|
||||
database.db
|
||||
.insert(books)
|
||||
.values({
|
||||
libraryId: library.id,
|
||||
seriesId: soloLeveling.id,
|
||||
title: filePath.includes("T03") ? "Solo Leveling T03" : "Solo Leveling 003",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
identifiersJson: null,
|
||||
localMetadataJson: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
volumeNumber: 3,
|
||||
volumeLabel: filePath.includes("T03") ? "T03" : "003",
|
||||
format: "cbz",
|
||||
filePath,
|
||||
coverPath: null,
|
||||
metadataStatus: "none",
|
||||
metadataProvenanceJson: JSON.stringify({ title: "local" }),
|
||||
scanStatus: "succeeded",
|
||||
enrichmentStatus: "idle",
|
||||
fileSize: 42,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
const result = service.getSeries(soloLeveling.id);
|
||||
|
||||
expect(result).toMatchObject({ id: soloLeveling.id, title: "Solo Leveling", normalizedTitle: "solo leveling" });
|
||||
expect(result.books).toHaveLength(2);
|
||||
expect(result.books.map((book) => [book.title, book.volumeNumber])).toEqual([
|
||||
["Solo Leveling 003", 3],
|
||||
["Solo Leveling T03", 3]
|
||||
]);
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
});
|
||||
|
||||
function createDatabase(): DatabaseService {
|
||||
const dir = mkdtempSync(join(tmpdir(), "readabook-books-service-"));
|
||||
tempDirs.push(dir);
|
||||
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
|
||||
process.env.STORAGE_DIR = join(dir, "storage");
|
||||
return new DatabaseService();
|
||||
}
|
||||
|
||||
function canLoadBetterSqlite(): boolean {
|
||||
try {
|
||||
const database = createDatabase();
|
||||
database.onModuleDestroy();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,8 @@ 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";
|
||||
import { books, series } from "../database/schema.js";
|
||||
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
|
||||
|
||||
@Injectable()
|
||||
export class BooksService {
|
||||
@ -26,7 +27,8 @@ export class BooksService {
|
||||
.orderBy(books.title)
|
||||
.limit(query.limit)
|
||||
.offset(query.offset)
|
||||
.all();
|
||||
.all()
|
||||
.map((book) => this.mapBookSelect(book));
|
||||
}
|
||||
|
||||
search(q: string, limit = 50, offset = 0) {
|
||||
@ -42,19 +44,32 @@ export class BooksService {
|
||||
`
|
||||
)
|
||||
.all(`${q.replace(/"/g, '""')}*`, limit, offset);
|
||||
return (rows as Array<Record<string, unknown>>).map(mapBookRow);
|
||||
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
|
||||
}
|
||||
|
||||
get(id: number) {
|
||||
const book = this.database.db.select().from(books).where(eq(books.id, id)).get();
|
||||
if (!book) {
|
||||
throw new NotFoundException("Book not found");
|
||||
}
|
||||
return book;
|
||||
return this.mapBookSelect(this.getRecord(id));
|
||||
}
|
||||
|
||||
listSeries() {
|
||||
return this.database.db.select().from(series).orderBy(series.title).all();
|
||||
}
|
||||
|
||||
getSeries(id: number) {
|
||||
const row = this.database.db.select().from(series).where(eq(series.id, id)).get();
|
||||
if (!row) throw new NotFoundException("Series not found");
|
||||
const seriesBooks = this.database.db
|
||||
.select()
|
||||
.from(books)
|
||||
.where(eq(books.seriesId, id))
|
||||
.orderBy(books.volumeNumber, books.title)
|
||||
.all()
|
||||
.map((book) => this.mapBookSelect(book));
|
||||
return { ...row, books: seriesBooks };
|
||||
}
|
||||
|
||||
streamFile(id: number, range?: string) {
|
||||
const book = this.get(id);
|
||||
const book = this.getRecord(id);
|
||||
if (!existsSync(book.filePath)) {
|
||||
throw new NotFoundException("Book file not found on disk");
|
||||
}
|
||||
@ -72,7 +87,7 @@ export class BooksService {
|
||||
}
|
||||
|
||||
streamCover(id: number) {
|
||||
const book = this.get(id);
|
||||
const book = this.getRecord(id);
|
||||
if (!book.coverPath || !existsSync(book.coverPath)) {
|
||||
throw new NotFoundException("Cover not found");
|
||||
}
|
||||
@ -80,7 +95,7 @@ export class BooksService {
|
||||
}
|
||||
|
||||
async listComicPages(id: number) {
|
||||
const book = this.get(id);
|
||||
const book = this.getRecord(id);
|
||||
this.assertComicArchiveBook(book);
|
||||
const pages = book.format === "cbr" ? await listCbrImageEntries(book.filePath) : listCbzImageEntries(book.filePath);
|
||||
return {
|
||||
@ -91,7 +106,7 @@ export class BooksService {
|
||||
}
|
||||
|
||||
async readComicPage(id: number, page: number) {
|
||||
const book = this.get(id);
|
||||
const book = this.getRecord(id);
|
||||
this.assertComicArchiveBook(book);
|
||||
try {
|
||||
const result =
|
||||
@ -108,6 +123,14 @@ export class BooksService {
|
||||
return this.database.db.select({ count: sql<number>`count(*)` }).from(books).get()?.count ?? 0;
|
||||
}
|
||||
|
||||
private getRecord(id: number): typeof books.$inferSelect {
|
||||
const book = this.database.db.select().from(books).where(eq(books.id, id)).get();
|
||||
if (!book) {
|
||||
throw new NotFoundException("Book not found");
|
||||
}
|
||||
return book;
|
||||
}
|
||||
|
||||
private assertComicArchiveBook(book: typeof books.$inferSelect): void {
|
||||
if (book.format !== "cbz" && book.format !== "cbr") {
|
||||
throw new BadRequestException("Book is not a comic archive");
|
||||
@ -116,6 +139,51 @@ export class BooksService {
|
||||
throw new NotFoundException("Book file not found on disk");
|
||||
}
|
||||
}
|
||||
|
||||
private mapBookRow(row: Record<string, unknown>) {
|
||||
const seriesId = nullable(row.series_id);
|
||||
return {
|
||||
id: Number(row.id),
|
||||
libraryId: Number(row.library_id),
|
||||
seriesId: seriesId ? Number(seriesId) : null,
|
||||
title: String(row.title),
|
||||
author: nullable(row.author),
|
||||
description: nullable(row.description),
|
||||
isbn: nullable(row.isbn),
|
||||
isbn13: nullable(row.isbn13),
|
||||
language: nullable(row.language),
|
||||
publisher: nullable(row.publisher),
|
||||
publishedDate: normalizePublishedDate(nullable(row.published_date)),
|
||||
volumeNumber: row.volume_number === null || row.volume_number === undefined ? null : Number(row.volume_number),
|
||||
volumeLabel: nullable(row.volume_label),
|
||||
format: row.format,
|
||||
filePath: String(row.file_path),
|
||||
coverPath: nullable(row.cover_path),
|
||||
metadataStatus: metadataStatusValue(row.metadata_status),
|
||||
metadataProvenance: parseObject(row.metadata_provenance_json),
|
||||
series: seriesId ? this.getSeriesRecord(Number(seriesId)) : null,
|
||||
scanStatus: statusValue(row.scan_status),
|
||||
enrichmentStatus: statusValue(row.enrichment_status),
|
||||
fileSize: Number(row.file_size),
|
||||
fileMtime: String(row.file_mtime),
|
||||
createdAt: String(row.created_at),
|
||||
updatedAt: String(row.updated_at)
|
||||
};
|
||||
}
|
||||
|
||||
private mapBookSelect(row: typeof books.$inferSelect) {
|
||||
const { metadataProvenanceJson: _metadataProvenanceJson, ...book } = row;
|
||||
return {
|
||||
...book,
|
||||
publishedDate: normalizePublishedDate(row.publishedDate),
|
||||
metadataProvenance: parseObject(row.metadataProvenanceJson),
|
||||
series: row.seriesId ? this.getSeriesRecord(row.seriesId) : null
|
||||
};
|
||||
}
|
||||
|
||||
private getSeriesRecord(id: number) {
|
||||
return this.database.db.select().from(series).where(eq(series.id, id)).get() ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseByteRange(range: string | undefined, size: number): { start: number; end: number; partial: boolean } {
|
||||
@ -159,28 +227,25 @@ function lookupMime(entryName: string): string {
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
function mapBookRow(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
libraryId: Number(row.library_id),
|
||||
title: String(row.title),
|
||||
author: nullable(row.author),
|
||||
description: nullable(row.description),
|
||||
isbn: nullable(row.isbn),
|
||||
isbn13: nullable(row.isbn13),
|
||||
language: nullable(row.language),
|
||||
publisher: nullable(row.publisher),
|
||||
publishedDate: nullable(row.published_date),
|
||||
format: row.format,
|
||||
filePath: String(row.file_path),
|
||||
coverPath: nullable(row.cover_path),
|
||||
fileSize: Number(row.file_size),
|
||||
fileMtime: String(row.file_mtime),
|
||||
createdAt: String(row.created_at),
|
||||
updatedAt: String(row.updated_at)
|
||||
};
|
||||
}
|
||||
|
||||
function nullable(value: unknown): string | null {
|
||||
return value === null || value === undefined ? null : String(value);
|
||||
}
|
||||
|
||||
function statusValue(value: unknown): "idle" | "running" | "succeeded" | "failed" {
|
||||
return value === "running" || value === "succeeded" || value === "failed" ? value : "idle";
|
||||
}
|
||||
|
||||
function metadataStatusValue(value: unknown): "enriched" | "partial" | "none" {
|
||||
return value === "enriched" || value === "partial" ? value : "none";
|
||||
}
|
||||
|
||||
function parseObject(value: unknown): Record<string, string> {
|
||||
if (typeof value !== "string") return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
return Object.fromEntries(Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
19
apps/api/src/books/series.controller.ts
Normal file
19
apps/api/src/books/series.controller.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/auth.guard.js";
|
||||
import { BooksService } from "./books.service.js";
|
||||
|
||||
@Controller("series")
|
||||
@UseGuards(AuthGuard)
|
||||
export class SeriesController {
|
||||
constructor(private readonly books: BooksService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.books.listSeries();
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
get(@Param("id") id: string) {
|
||||
return this.books.getSeries(Number(id));
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, 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";
|
||||
@ -46,6 +46,7 @@ export async function readCbrPage(
|
||||
throw new Error("CBR page not found");
|
||||
}
|
||||
|
||||
mkdirSync(storageDir, { recursive: true });
|
||||
const tempDir = mkdtempSync(join(storageDir, "cbr-page-"));
|
||||
const safeName = `page${extname(page.entryName).toLowerCase() || ".jpg"}`;
|
||||
try {
|
||||
|
||||
@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DatabaseService } from "./database.service.js";
|
||||
import { books, libraries, series } from "./schema.js";
|
||||
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const previousStorageDir = process.env.STORAGE_DIR;
|
||||
@ -25,6 +26,7 @@ describe("database migrations", () => {
|
||||
const storageDir = join(dir, "storage");
|
||||
|
||||
const legacy = new Database(databasePath);
|
||||
const now = new Date().toISOString();
|
||||
legacy.exec(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@ -84,6 +86,17 @@ describe("database migrations", () => {
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO libraries (id, name, path, enabled, created_at, updated_at)
|
||||
VALUES (1, 'Corpus', '/library', 1, '${now}', '${now}');
|
||||
INSERT INTO books (
|
||||
library_id, title, author, description, isbn, language, publisher, published_date,
|
||||
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
||||
)
|
||||
VALUES
|
||||
(1, 'Solo Leveling T03', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Solo Leveling T03.cbz', NULL, 42, '${now}', '${now}', '${now}'),
|
||||
(1, 'Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz.cbz', NULL, 42, '${now}', '${now}', '${now}'),
|
||||
(1, 'Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz', NULL, 42, '${now}', '${now}', '${now}');
|
||||
`);
|
||||
legacy.close();
|
||||
|
||||
@ -94,18 +107,130 @@ describe("database migrations", () => {
|
||||
const bookColumns = database.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>;
|
||||
const bookIndexes = database.sqlite.prepare("PRAGMA index_list(books)").all() as Array<{ name: string }>;
|
||||
const metadataSources = database.sqlite.prepare("SELECT provider FROM metadata_source_config ORDER BY priority").all() as Array<{ provider: string }>;
|
||||
const seriesRows = database.sqlite
|
||||
.prepare(
|
||||
`
|
||||
SELECT books.title, books.volume_number, books.volume_label, series.title AS series_title, series.normalized_title
|
||||
FROM books
|
||||
JOIN series ON series.id = books.series_id
|
||||
ORDER BY books.title
|
||||
`
|
||||
)
|
||||
.all() as Array<{
|
||||
title: string;
|
||||
volume_number: number | null;
|
||||
volume_label: string | null;
|
||||
series_title: string;
|
||||
normalized_title: string;
|
||||
}>;
|
||||
const automationSettings = database.sqlite.prepare("SELECT id, isbn_priority_enabled FROM automation_settings").get() as
|
||||
| { id: number; isbn_priority_enabled: number }
|
||||
| undefined;
|
||||
|
||||
expect(bookColumns.map((column) => column.name)).toContain("isbn13");
|
||||
expect(bookColumns.map((column) => column.name)).toContain("identifiers_json");
|
||||
expect(bookColumns.map((column) => column.name)).toContain("scan_status");
|
||||
expect(bookColumns.map((column) => column.name)).toContain("enrichment_status");
|
||||
expect(bookColumns.map((column) => column.name)).toContain("series_id");
|
||||
expect(bookColumns.map((column) => column.name)).toContain("volume_number");
|
||||
expect(bookColumns.map((column) => column.name)).toContain("volume_label");
|
||||
expect(bookIndexes.map((index) => index.name)).toContain("books_isbn13_idx");
|
||||
expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf"]);
|
||||
expect(bookIndexes.map((index) => index.name)).toContain("books_series_idx");
|
||||
expect(seriesRows).toEqual([
|
||||
{
|
||||
title: "Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+",
|
||||
volume_number: 1,
|
||||
volume_label: "T01",
|
||||
series_title: "Dragon Ball SD",
|
||||
normalized_title: "dragon ball sd"
|
||||
},
|
||||
{
|
||||
title: "Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz",
|
||||
volume_number: 1,
|
||||
volume_label: "T01",
|
||||
series_title: "Eyeshield 21",
|
||||
normalized_title: "eyeshield 21"
|
||||
},
|
||||
{
|
||||
title: "Solo Leveling T03",
|
||||
volume_number: 3,
|
||||
volume_label: "T03",
|
||||
series_title: "Solo Leveling",
|
||||
normalized_title: "solo leveling"
|
||||
}
|
||||
]);
|
||||
expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"]);
|
||||
expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 });
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("backfills missing Daredevil volume numbers when series already exists", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "readabook-series-backfill-"));
|
||||
tempDirs.push(dir);
|
||||
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
|
||||
process.env.STORAGE_DIR = join(dir, "storage");
|
||||
|
||||
const first = new DatabaseService();
|
||||
const now = first.now();
|
||||
const library = first.db
|
||||
.insert(libraries)
|
||||
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
|
||||
.returning()
|
||||
.get();
|
||||
const daredevil = first.db
|
||||
.insert(series)
|
||||
.values({
|
||||
title: "Daredevil",
|
||||
normalizedTitle: "daredevil",
|
||||
description: null,
|
||||
publisher: null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
first.db
|
||||
.insert(books)
|
||||
.values({
|
||||
libraryId: library.id,
|
||||
seriesId: daredevil.id,
|
||||
title: "Daredevil",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
identifiersJson: null,
|
||||
localMetadataJson: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
volumeNumber: null,
|
||||
volumeLabel: null,
|
||||
format: "cbz",
|
||||
filePath: "/library/Daredevil - 001[Sebmov].cbz",
|
||||
coverPath: null,
|
||||
metadataStatus: "none",
|
||||
metadataProvenanceJson: JSON.stringify({ title: "local" }),
|
||||
scanStatus: "succeeded",
|
||||
enrichmentStatus: "idle",
|
||||
fileSize: 42,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.run();
|
||||
first.onModuleDestroy();
|
||||
|
||||
const second = new DatabaseService();
|
||||
const row = second.sqlite.prepare("SELECT volume_number, volume_label FROM books WHERE file_path = ?").get(
|
||||
"/library/Daredevil - 001[Sebmov].cbz"
|
||||
) as { volume_number: number | null; volume_label: string | null };
|
||||
|
||||
expect(row).toEqual({ volume_number: 1, volume_label: "001" });
|
||||
|
||||
second.onModuleDestroy();
|
||||
});
|
||||
});
|
||||
|
||||
function canLoadBetterSqlite(): boolean {
|
||||
|
||||
@ -2,6 +2,7 @@ import { Injectable, OnModuleDestroy } from "@nestjs/common";
|
||||
import Database from "better-sqlite3";
|
||||
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { AppConfig, loadConfig } from "../config/env.js";
|
||||
import { extractSeriesVolume } from "../metadata/use-cases/extract-series-volume.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
@Injectable()
|
||||
@ -49,9 +50,20 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS series (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
normalized_title TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
publisher TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS books (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
series_id INTEGER REFERENCES series(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
description TEXT,
|
||||
@ -62,9 +74,15 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
language TEXT,
|
||||
publisher TEXT,
|
||||
published_date TEXT,
|
||||
volume_number INTEGER,
|
||||
volume_label TEXT,
|
||||
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
cover_path TEXT,
|
||||
metadata_status TEXT NOT NULL DEFAULT 'none' CHECK (metadata_status IN ('enriched','partial','none')),
|
||||
metadata_provenance_json TEXT,
|
||||
scan_status TEXT NOT NULL DEFAULT 'idle' CHECK (scan_status IN ('idle','running','succeeded','failed')),
|
||||
enrichment_status TEXT NOT NULL DEFAULT 'idle' CHECK (enrichment_status IN ('idle','running','succeeded','failed')),
|
||||
file_size INTEGER NOT NULL,
|
||||
file_mtime TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
@ -93,7 +111,7 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_source_config (
|
||||
provider TEXT PRIMARY KEY CHECK (provider IN ('local','openlibrary','googlebooks','bnf')),
|
||||
provider TEXT PRIMARY KEY CHECK (provider IN ('local','openlibrary','googlebooks','bnf','mangadex','comicvine')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
priority INTEGER NOT NULL,
|
||||
api_key TEXT,
|
||||
@ -143,8 +161,11 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
END;
|
||||
`);
|
||||
this.ensureBooksSupportsComicArchives();
|
||||
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
|
||||
this.ensureBooksMetadataColumns();
|
||||
this.ensureSeriesModel();
|
||||
this.ensureReaderPreferencesTable();
|
||||
this.ensureMetadataSourceConfigSupportsComicProviders();
|
||||
this.ensureMetadataSourceConfigColumns();
|
||||
this.ensureAutomationSettingsColumns();
|
||||
this.ensureMetadataDefaults();
|
||||
@ -170,6 +191,7 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
CREATE TABLE books (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
series_id INTEGER REFERENCES series(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
description TEXT,
|
||||
@ -180,9 +202,15 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
language TEXT,
|
||||
publisher TEXT,
|
||||
published_date TEXT,
|
||||
volume_number INTEGER,
|
||||
volume_label TEXT,
|
||||
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
cover_path TEXT,
|
||||
metadata_status TEXT NOT NULL DEFAULT 'none' CHECK (metadata_status IN ('enriched','partial','none')),
|
||||
metadata_provenance_json TEXT,
|
||||
scan_status TEXT NOT NULL DEFAULT 'idle' CHECK (scan_status IN ('idle','running','succeeded','failed')),
|
||||
enrichment_status TEXT NOT NULL DEFAULT 'idle' CHECK (enrichment_status IN ('idle','running','succeeded','failed')),
|
||||
file_size INTEGER NOT NULL,
|
||||
file_mtime TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
@ -190,11 +218,13 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
);
|
||||
INSERT INTO books (
|
||||
id, library_id, title, author, description, isbn, isbn13, identifiers_json, local_metadata_json, language, publisher, published_date,
|
||||
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
||||
volume_number, volume_label, format, file_path, cover_path, metadata_status, metadata_provenance_json, scan_status, enrichment_status, file_size, file_mtime, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, library_id, title, author, description, isbn, NULL, NULL, NULL, language, publisher, published_date,
|
||||
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
||||
NULL, NULL,
|
||||
format, file_path, cover_path, CASE WHEN cover_path IS NOT NULL OR author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL THEN 'partial' ELSE 'none' END, NULL,
|
||||
'idle', 'idle', file_size, file_mtime, created_at, updated_at
|
||||
FROM books_legacy_format;
|
||||
DROP TABLE books_legacy_format;
|
||||
COMMIT;
|
||||
@ -238,8 +268,95 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
if (!names.has("local_metadata_json")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN local_metadata_json TEXT");
|
||||
}
|
||||
if (!names.has("metadata_status")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN metadata_status TEXT NOT NULL DEFAULT 'none'");
|
||||
this.sqlite.exec(`
|
||||
UPDATE books
|
||||
SET metadata_status = CASE
|
||||
WHEN cover_path IS NOT NULL AND (author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL) THEN 'enriched'
|
||||
WHEN cover_path IS NOT NULL OR author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL THEN 'partial'
|
||||
ELSE 'none'
|
||||
END
|
||||
`);
|
||||
}
|
||||
if (!names.has("metadata_provenance_json")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN metadata_provenance_json TEXT");
|
||||
}
|
||||
if (!names.has("scan_status")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN scan_status TEXT NOT NULL DEFAULT 'idle'");
|
||||
}
|
||||
if (!names.has("enrichment_status")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN enrichment_status TEXT NOT NULL DEFAULT 'idle'");
|
||||
}
|
||||
if (!names.has("series_id")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN series_id INTEGER REFERENCES series(id) ON DELETE SET NULL");
|
||||
}
|
||||
if (!names.has("volume_number")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN volume_number INTEGER");
|
||||
}
|
||||
if (!names.has("volume_label")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN volume_label TEXT");
|
||||
}
|
||||
this.sqlite.exec(`
|
||||
UPDATE books
|
||||
SET published_date = NULL
|
||||
WHERE published_date IS NOT NULL
|
||||
AND (
|
||||
trim(published_date) = '0000'
|
||||
OR substr(trim(published_date), 1, 10) IN ('0001-01-01', '0101-01-01', '1970-01-01')
|
||||
OR CAST(substr(trim(published_date), 1, 4) AS INTEGER) < 1500
|
||||
OR CAST(substr(trim(published_date), 1, 4) AS INTEGER) > 2027
|
||||
)
|
||||
`);
|
||||
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13)");
|
||||
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_local_metadata_idx ON books(local_metadata_json)");
|
||||
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_series_idx ON books(series_id)");
|
||||
}
|
||||
|
||||
private ensureSeriesModel(): void {
|
||||
this.sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS series (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
normalized_title TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
publisher TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS series_normalized_title_unique ON series(normalized_title);
|
||||
CREATE INDEX IF NOT EXISTS books_series_idx ON books(series_id);
|
||||
`);
|
||||
this.backfillSeries();
|
||||
}
|
||||
|
||||
private backfillSeries(): void {
|
||||
const rows = this.sqlite.prepare("SELECT id, title, file_path, series_id FROM books WHERE series_id IS NULL OR volume_number IS NULL").all() as Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
file_path: string;
|
||||
series_id: number | null;
|
||||
}>;
|
||||
if (!rows.length) return;
|
||||
const now = this.now();
|
||||
const insertSeries = this.sqlite.prepare(`
|
||||
INSERT INTO series (title, normalized_title, description, publisher, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, NULL, ?, ?)
|
||||
ON CONFLICT(normalized_title) DO UPDATE SET title = excluded.title, updated_at = excluded.updated_at
|
||||
RETURNING id
|
||||
`);
|
||||
const updateBook = this.sqlite.prepare("UPDATE books SET series_id = ?, volume_number = ?, volume_label = ? WHERE id = ?");
|
||||
const transaction = this.sqlite.transaction(() => {
|
||||
for (const row of rows) {
|
||||
const parsed = extractSeriesVolume(row.title, row.file_path);
|
||||
if (row.series_id !== null && parsed.volumeNumber === null) continue;
|
||||
const seriesId =
|
||||
row.series_id ??
|
||||
(insertSeries.get(parsed.seriesTitle, parsed.normalizedSeriesTitle, now, now) as { id: number }).id;
|
||||
updateBook.run(seriesId, parsed.volumeNumber, parsed.volumeLabel, row.id);
|
||||
}
|
||||
});
|
||||
transaction();
|
||||
}
|
||||
|
||||
private ensureReaderPreferencesTable(): void {
|
||||
@ -278,6 +395,31 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private ensureMetadataSourceConfigSupportsComicProviders(): void {
|
||||
const table = this.sqlite
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'metadata_source_config'")
|
||||
.get() as { sql?: string } | undefined;
|
||||
if (!table?.sql || (table.sql.includes("'mangadex'") && table.sql.includes("'comicvine'"))) return;
|
||||
|
||||
this.sqlite.exec(`
|
||||
BEGIN;
|
||||
ALTER TABLE metadata_source_config RENAME TO metadata_source_config_legacy_provider;
|
||||
CREATE TABLE metadata_source_config (
|
||||
provider TEXT PRIMARY KEY CHECK (provider IN ('local','openlibrary','googlebooks','bnf','mangadex','comicvine')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
priority INTEGER NOT NULL,
|
||||
api_key TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO metadata_source_config (provider, enabled, priority, api_key, created_at, updated_at)
|
||||
SELECT provider, enabled, priority, api_key, created_at, updated_at
|
||||
FROM metadata_source_config_legacy_provider;
|
||||
DROP TABLE metadata_source_config_legacy_provider;
|
||||
COMMIT;
|
||||
`);
|
||||
}
|
||||
|
||||
private ensureAutomationSettingsColumns(): void {
|
||||
const names = this.columnNames("automation_settings");
|
||||
const now = sqlString(this.now());
|
||||
@ -322,6 +464,8 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
insertSource.run("openlibrary", this.config.openLibraryEnabled ? 1 : 0, 1, now, now);
|
||||
insertSource.run("googlebooks", 0, 2, now, now);
|
||||
insertSource.run("bnf", 0, 3, now, now);
|
||||
insertSource.run("mangadex", 1, 4, now, now);
|
||||
insertSource.run("comicvine", 0, 5, now, now);
|
||||
|
||||
this.sqlite
|
||||
.prepare(
|
||||
|
||||
@ -23,6 +23,20 @@ export const libraries = sqliteTable("libraries", {
|
||||
updatedAt: text("updated_at").notNull()
|
||||
});
|
||||
|
||||
export const series = sqliteTable(
|
||||
"series",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
title: text("title").notNull(),
|
||||
normalizedTitle: text("normalized_title").notNull(),
|
||||
description: text("description"),
|
||||
publisher: text("publisher"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
},
|
||||
(table) => ({ normalizedTitleIdx: uniqueIndex("series_normalized_title_unique").on(table.normalizedTitle) })
|
||||
);
|
||||
|
||||
export const books = sqliteTable(
|
||||
"books",
|
||||
{
|
||||
@ -30,6 +44,7 @@ export const books = sqliteTable(
|
||||
libraryId: integer("library_id")
|
||||
.notNull()
|
||||
.references(() => libraries.id, { onDelete: "cascade" }),
|
||||
seriesId: integer("series_id").references(() => series.id, { onDelete: "set null" }),
|
||||
title: text("title").notNull(),
|
||||
author: text("author"),
|
||||
description: text("description"),
|
||||
@ -40,9 +55,15 @@ export const books = sqliteTable(
|
||||
language: text("language"),
|
||||
publisher: text("publisher"),
|
||||
publishedDate: text("published_date"),
|
||||
volumeNumber: integer("volume_number"),
|
||||
volumeLabel: text("volume_label"),
|
||||
format: text("format", { enum: ["epub", "pdf", "cbz", "cbr"] }).notNull(),
|
||||
filePath: text("file_path").notNull(),
|
||||
coverPath: text("cover_path"),
|
||||
metadataStatus: text("metadata_status", { enum: ["enriched", "partial", "none"] }).notNull().default("none"),
|
||||
metadataProvenanceJson: text("metadata_provenance_json"),
|
||||
scanStatus: text("scan_status", { enum: ["idle", "running", "succeeded", "failed"] }).notNull().default("idle"),
|
||||
enrichmentStatus: text("enrichment_status", { enum: ["idle", "running", "succeeded", "failed"] }).notNull().default("idle"),
|
||||
fileSize: integer("file_size").notNull(),
|
||||
fileMtime: text("file_mtime").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
@ -98,7 +119,7 @@ export const jobs = sqliteTable("jobs", {
|
||||
});
|
||||
|
||||
export const metadataSourceConfig = sqliteTable("metadata_source_config", {
|
||||
provider: text("provider", { enum: ["local", "openlibrary", "googlebooks", "bnf"] }).primaryKey(),
|
||||
provider: text("provider", { enum: ["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"] }).primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
priority: integer("priority").notNull(),
|
||||
apiKey: text("api_key"),
|
||||
|
||||
@ -2,6 +2,8 @@ import { Injectable } from "@nestjs/common";
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
|
||||
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
|
||||
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
|
||||
import { providerFetch, providerHttpError } from "./provider-fetch.js";
|
||||
|
||||
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", removeNSPrefix: true });
|
||||
|
||||
@ -36,8 +38,8 @@ export class BnfProvider implements MetadataProvider {
|
||||
url.searchParams.set("query", query);
|
||||
url.searchParams.set("maximumRecords", String(maximumRecords));
|
||||
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||
if (!response.ok) return [];
|
||||
const response = await providerFetch(this.id, url, { timeoutMs: 5000 });
|
||||
if (!response.ok) throw await providerHttpError(this.id, response, `BnF HTTP ${response.status}`);
|
||||
const parsed = parser.parse(await response.text());
|
||||
const records = asArray(parsed?.searchRetrieveResponse?.records?.record)
|
||||
.map((entry) => (entry.recordData as Record<string, unknown> | undefined)?.record)
|
||||
@ -53,7 +55,7 @@ export class BnfProvider implements MetadataProvider {
|
||||
isbn: bestIsbn(fields, expectedIsbn13),
|
||||
language: subfield(fields, "101", "a"),
|
||||
publisher: subfield(fields, "210", "c") ?? subfield(fields, "214", "c"),
|
||||
publishedDate: cleanDate(subfield(fields, "210", "d") ?? subfield(fields, "214", "d"))
|
||||
publishedDate: normalizePublishedDate(cleanDate(subfield(fields, "210", "d") ?? subfield(fields, "214", "d")))
|
||||
};
|
||||
})
|
||||
.sort((left, right) => Number(Boolean(right.isbn)) - Number(Boolean(left.isbn)));
|
||||
|
||||
160
apps/api/src/metadata/adapters/comic-vine.provider.ts
Normal file
160
apps/api/src/metadata/adapters/comic-vine.provider.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
|
||||
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
|
||||
import { providerFetch } from "./provider-fetch.js";
|
||||
|
||||
@Injectable()
|
||||
export class ComicVineProvider implements MetadataProvider {
|
||||
readonly id = "comicvine" as const;
|
||||
|
||||
async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null> {
|
||||
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, config);
|
||||
return matches[0] ?? null;
|
||||
}
|
||||
|
||||
async searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise<MetadataMatch[]> {
|
||||
assertApiKey(config);
|
||||
const title = cleanComicTitle(query.title);
|
||||
const relaxed = title.replace(/\b\d{1,3}\b/g, " ").replace(/\s+/g, " ").trim();
|
||||
const matches = [
|
||||
...(await searchComicVine("volume", title, config)),
|
||||
...(await searchComicVine("issue", title, config)),
|
||||
...(relaxed && relaxed !== title ? await searchComicVine("volume", relaxed, config) : [])
|
||||
];
|
||||
return rankMatches(query.title, dedupe(matches));
|
||||
}
|
||||
}
|
||||
|
||||
export class ComicVineProviderError extends Error {
|
||||
constructor(
|
||||
readonly code: "missing-key" | "invalid-key" | "rate-limit" | "http",
|
||||
readonly status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ComicVineProviderError";
|
||||
}
|
||||
}
|
||||
|
||||
async function searchComicVine(resource: "volume" | "issue", title: string, config: MetadataProviderConfig): Promise<MetadataMatch[]> {
|
||||
const url = new URL("https://comicvine.gamespot.com/api/search/");
|
||||
url.searchParams.set("api_key", config.apiKey!);
|
||||
url.searchParams.set("format", "json");
|
||||
url.searchParams.set("resources", resource);
|
||||
url.searchParams.set("query", title);
|
||||
url.searchParams.set("limit", "10");
|
||||
url.searchParams.set(
|
||||
"field_list",
|
||||
resource === "volume" ? "id,name,description,image,start_year,publisher" : "id,name,description,image,cover_date,store_date,volume"
|
||||
);
|
||||
const response = await providerFetch("comicvine", url, {
|
||||
headers: { "User-Agent": "ReadaBook/0.1 self-hosted metadata provider (Comic Vine; non-commercial)" },
|
||||
timeoutMs: 6000
|
||||
});
|
||||
const data = (await parseComicVineResponse(response)) as { results?: Array<Record<string, unknown>> };
|
||||
return (data.results ?? []).map((entry) => comicVineToMatch(resource, entry));
|
||||
}
|
||||
|
||||
function assertApiKey(config: MetadataProviderConfig): void {
|
||||
if (!config.apiKey?.trim()) {
|
||||
throw new ComicVineProviderError("missing-key", 0, "Comic Vine API key is required");
|
||||
}
|
||||
}
|
||||
|
||||
async function parseComicVineResponse(response: Response): Promise<unknown> {
|
||||
const data = (await response.json().catch(() => ({}))) as { status_code?: number; error?: string };
|
||||
if (response.status === 429) throw new ComicVineProviderError("rate-limit", response.status, data.error ?? "Comic Vine rate limit");
|
||||
if (response.status === 401 || response.status === 403) throw new ComicVineProviderError("invalid-key", response.status, data.error ?? "Comic Vine API key rejected");
|
||||
if (!response.ok) throw new ComicVineProviderError("http", response.status, data.error ?? `Comic Vine HTTP ${response.status}`);
|
||||
if (data.status_code && data.status_code !== 1) {
|
||||
const code = data.status_code === 100 || data.status_code === 101 ? "invalid-key" : "http";
|
||||
throw new ComicVineProviderError(code, 200, data.error ?? `Comic Vine status ${data.status_code}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function comicVineToMatch(resource: "volume" | "issue", entry: Record<string, unknown>): MetadataMatch & { comicVineRank?: number } {
|
||||
const volume = objectValue(entry.volume);
|
||||
const title = resource === "issue" ? [stringValue(volume.name), stringValue(entry.name)].filter(Boolean).join(" ") : stringValue(entry.name);
|
||||
return {
|
||||
title: title || undefined,
|
||||
description: cleanHtml(stringValue(entry.description)),
|
||||
publisher: stringValue(objectValue(entry.publisher).name),
|
||||
publishedDate: normalizePublishedDate(resource === "volume" ? stringValue(entry.start_year) : yearFromDate(stringValue(entry.cover_date) ?? stringValue(entry.store_date))),
|
||||
coverUrl: imageUrl(entry.image),
|
||||
sourceId: stringValue(entry.id)
|
||||
};
|
||||
}
|
||||
|
||||
function cleanComicTitle(value: string): string {
|
||||
return value
|
||||
.replace(/\.[A-Za-z0-9]{2,5}$/g, " ")
|
||||
.replace(/[._]+/g, " ")
|
||||
.replace(/\b(FRENCH|TRUEFRENCH|MULTI|CBZ|CBR|EPUB|PDF|eBook|ebook|scan|digital)\b/gi, " ")
|
||||
.replace(/\([^)]*\)/g, " ")
|
||||
.replace(/\b(e?bdz|Paprika\+?|emuleCenter(?:\.|\s+)net)\b/gi, " ")
|
||||
.replace(/\bT(?:ome)?\s*0?(\d{1,3})\b/gi, " $1 ")
|
||||
.replace(/[+]+/g, " ")
|
||||
.replace(/\s+-\s+/g, " ")
|
||||
.replace(/\s*-\s*$/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function rankMatches(originalTitle: string, matches: Array<MetadataMatch & { comicVineRank?: number }>): MetadataMatch[] {
|
||||
return [...matches]
|
||||
.map((match) => ({ ...match, comicVineRank: comicRank(originalTitle, match) }))
|
||||
.sort((left, right) => (right.comicVineRank ?? 0) - (left.comicVineRank ?? 0))
|
||||
.map(({ comicVineRank: _rank, ...match }) => match);
|
||||
}
|
||||
|
||||
function comicRank(originalTitle: string, match: MetadataMatch): number {
|
||||
let rank = tokenOverlap(cleanComicTitle(originalTitle), match.title ?? "") * 10;
|
||||
if (match.coverUrl) rank += 1;
|
||||
if (match.description) rank += 1;
|
||||
return rank;
|
||||
}
|
||||
|
||||
function dedupe(matches: MetadataMatch[]): MetadataMatch[] {
|
||||
const seen = new Set<string>();
|
||||
return matches.filter((match) => {
|
||||
const key = [match.sourceId, match.title].filter(Boolean).join("|");
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function cleanHtml(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
return value.replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/\s+/g, " ").trim() || null;
|
||||
}
|
||||
|
||||
function imageUrl(value: unknown): string | null {
|
||||
const image = objectValue(value);
|
||||
return stringValue(image.original_url) ?? stringValue(image.super_url) ?? stringValue(image.medium_url) ?? stringValue(image.small_url);
|
||||
}
|
||||
|
||||
function yearFromDate(value: string | null): string | null {
|
||||
return value?.match(/\b(1[5-9]\d{2}|20\d{2})\b/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function tokenOverlap(left: string, right: string): number {
|
||||
const leftTokens = new Set(normalizeTokens(left));
|
||||
const rightTokens = new Set(normalizeTokens(right));
|
||||
if (!leftTokens.size || !rightTokens.size) return 0;
|
||||
return [...leftTokens].filter((token) => rightTokens.has(token)).length / leftTokens.size;
|
||||
}
|
||||
|
||||
function normalizeTokens(value: string): string[] {
|
||||
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").split(" ").filter(Boolean);
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
if (typeof value === "number") return String(value);
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
|
||||
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
|
||||
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
|
||||
import { providerFetch } from "./provider-fetch.js";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleBooksProvider implements MetadataProvider {
|
||||
@ -12,54 +14,157 @@ export class GoogleBooksProvider implements MetadataProvider {
|
||||
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, config);
|
||||
return matches[0] ?? null;
|
||||
}
|
||||
const query = `isbn:${isbn}`;
|
||||
const url = new URL("https://www.googleapis.com/books/v1/volumes");
|
||||
url.searchParams.set("q", query);
|
||||
url.searchParams.set("maxResults", "1");
|
||||
url.searchParams.set("printType", "books");
|
||||
if (config.apiKey) url.searchParams.set("key", config.apiKey);
|
||||
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(4000) });
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as { items?: Array<{ volumeInfo?: Record<string, unknown> }> };
|
||||
const info = data.items?.[0]?.volumeInfo;
|
||||
if (!info) return null;
|
||||
return {
|
||||
title: stringValue(info.title) ?? undefined,
|
||||
author: arrayJoin(info.authors),
|
||||
description: stringValue(info.description),
|
||||
language: stringValue(info.language),
|
||||
publisher: stringValue(info.publisher),
|
||||
publishedDate: stringValue(info.publishedDate),
|
||||
isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, lookup.identifiers.isbn13)
|
||||
};
|
||||
const matches = await this.searchVolumes(`isbn:${isbn}`, config, lookup.title, lookup.author, lookup.identifiers.isbn13);
|
||||
return rankMatches(lookup.title, matches)[0] ?? null;
|
||||
}
|
||||
|
||||
async searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise<MetadataMatch[]> {
|
||||
const attempts = googleBookQueries(query);
|
||||
const matches: MetadataMatch[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const attempt of attempts) {
|
||||
for (const match of await this.searchVolumes(attempt, config, query.title, query.author, query.isbn ? toIsbn13(query.isbn) : null)) {
|
||||
const key = [match.sourceId, match.isbn, match.title, match.author].filter(Boolean).join("|");
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
matches.push(match);
|
||||
}
|
||||
}
|
||||
return rankMatches(query.title, matches);
|
||||
}
|
||||
|
||||
private async searchVolumes(
|
||||
googleQuery: string,
|
||||
config: MetadataProviderConfig,
|
||||
originalTitle: string,
|
||||
originalAuthor: string | null,
|
||||
expectedIsbn13: string | null
|
||||
): Promise<MetadataMatch[]> {
|
||||
const url = new URL("https://www.googleapis.com/books/v1/volumes");
|
||||
url.searchParams.set("q", `intitle:${query.title}${query.author ? `+inauthor:${query.author}` : ""}`);
|
||||
url.searchParams.set("maxResults", "5");
|
||||
url.searchParams.set("q", googleQuery);
|
||||
url.searchParams.set("maxResults", "10");
|
||||
url.searchParams.set("printType", "books");
|
||||
if (config.apiKey) url.searchParams.set("key", config.apiKey);
|
||||
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(4000) });
|
||||
if (!response.ok) return [];
|
||||
const data = (await response.json()) as { items?: Array<{ volumeInfo?: Record<string, unknown> }> };
|
||||
const response = await providerFetch(this.id, url, { timeoutMs: 4000 });
|
||||
const data = (await parseGoogleResponse(response)) as { items?: Array<{ id?: string; volumeInfo?: Record<string, unknown> }> };
|
||||
return (data.items ?? [])
|
||||
.map((item) => item.volumeInfo)
|
||||
.filter((info): info is Record<string, unknown> => Boolean(info))
|
||||
.map((info) => ({
|
||||
.map((item) => ({ sourceId: item.id, info: item.volumeInfo }))
|
||||
.filter((item): item is { sourceId: string | undefined; info: Record<string, unknown> } => Boolean(item.info))
|
||||
.map(({ sourceId, info }) => ({
|
||||
title: stringValue(info.title) ?? undefined,
|
||||
author: arrayJoin(info.authors),
|
||||
description: stringValue(info.description),
|
||||
language: stringValue(info.language),
|
||||
publisher: stringValue(info.publisher),
|
||||
publishedDate: stringValue(info.publishedDate),
|
||||
isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, query.isbn ? toIsbn13(query.isbn) : null)
|
||||
publishedDate: normalizePublishedDate(stringValue(info.publishedDate)),
|
||||
isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, expectedIsbn13),
|
||||
coverUrl: coverUrl(info.imageLinks),
|
||||
sourceId,
|
||||
identifiers: { candidates: isbnCandidates(info.industryIdentifiers) },
|
||||
googleRank: googleRank(originalTitle, originalAuthor, info)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export class GoogleBooksProviderError extends Error {
|
||||
constructor(
|
||||
readonly code: "quota" | "auth" | "http",
|
||||
readonly status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "GoogleBooksProviderError";
|
||||
}
|
||||
}
|
||||
|
||||
async function parseGoogleResponse(response: Response): Promise<unknown> {
|
||||
const data = (await response.json().catch(() => ({}))) as { error?: { message?: string; status?: string } };
|
||||
if (response.ok) return data;
|
||||
const message = data.error?.message ?? `Google Books HTTP ${response.status}`;
|
||||
if (response.status === 429) throw new GoogleBooksProviderError("quota", response.status, message);
|
||||
if (response.status === 401 || response.status === 403) throw new GoogleBooksProviderError("auth", response.status, message);
|
||||
throw new GoogleBooksProviderError("http", response.status, message);
|
||||
}
|
||||
|
||||
function googleBookQueries(query: MetadataSearchQuery): string[] {
|
||||
const cleaned = cleanGoogleBooksTitle(query.title);
|
||||
const relaxed = relaxSeriesTitle(cleaned);
|
||||
return [
|
||||
query.isbn ? `isbn:${query.isbn}` : null,
|
||||
googleTitleQuery(cleaned, query.author, true),
|
||||
googleTitleQuery(cleaned, query.author, false),
|
||||
relaxed !== cleaned ? googleTitleQuery(relaxed, query.author, true) : null,
|
||||
relaxed !== cleaned ? googleTitleQuery(relaxed, null, false) : null,
|
||||
googleTitleQuery(cleaned, null, false)
|
||||
].filter((value, index, values): value is string => Boolean(value) && values.indexOf(value) === index);
|
||||
}
|
||||
|
||||
function googleTitleQuery(title: string, author: string | null, quoted: boolean): string {
|
||||
const titlePart = quoted ? `intitle:"${title.replace(/"/g, " ")}"` : `intitle:${title}`;
|
||||
return author ? `${titlePart}+inauthor:${author}` : titlePart;
|
||||
}
|
||||
|
||||
function cleanGoogleBooksTitle(value: string): string {
|
||||
return value
|
||||
.replace(/\.[A-Za-z0-9]{2,5}$/g, " ")
|
||||
.replace(/[._]+/g, " ")
|
||||
.replace(/\b(FRENCH|TRUEFRENCH|MULTI|CBZ|CBR|EPUB|PDF|eBook|ebook|scan|digital)\b/gi, " ")
|
||||
.replace(/\b(e?bdz|Paprika\+?|emuleCenter\.net)\b/gi, " ")
|
||||
.replace(/\bT(?:ome)?\s*0?(\d{1,3})\b/gi, " $1 ")
|
||||
.replace(/\bVol(?:ume)?\.?\s*0?(\d{1,3})\b/gi, " $1 ")
|
||||
.replace(/[+]+/g, " ")
|
||||
.replace(/\s+-\s+/g, " ")
|
||||
.replace(/\s*-\s*$/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function relaxSeriesTitle(value: string): string {
|
||||
return value.replace(/\b\d{1,3}\b/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function rankMatches(originalTitle: string, matches: Array<MetadataMatch & { googleRank?: number }>): MetadataMatch[] {
|
||||
return [...matches]
|
||||
.sort((left, right) => (right.googleRank ?? 0) - (left.googleRank ?? 0))
|
||||
.map(({ googleRank: _googleRank, ...match }) => match);
|
||||
}
|
||||
|
||||
function googleRank(originalTitle: string, originalAuthor: string | null, info: Record<string, unknown>): number {
|
||||
const expectedVolume = volumeNumber(originalTitle);
|
||||
const candidateTitle = [stringValue(info.title), stringValue(info.subtitle)].filter(Boolean).join(" ");
|
||||
let rank = tokenOverlap(cleanGoogleBooksTitle(originalTitle), candidateTitle) * 10;
|
||||
if (expectedVolume) {
|
||||
const candidateVolume = volumeNumber(candidateTitle);
|
||||
rank += candidateVolume === expectedVolume ? 6 : candidateVolume ? -4 : 0;
|
||||
}
|
||||
if (originalAuthor && arrayJoin(info.authors)?.toLowerCase().includes(originalAuthor.toLowerCase())) rank += 2;
|
||||
if (stringValue(info.description)) rank += 1;
|
||||
if (coverUrl(info.imageLinks)) rank += 1;
|
||||
return rank;
|
||||
}
|
||||
|
||||
function volumeNumber(value: string): string | null {
|
||||
return value.match(/\b(?:T|tome|vol(?:ume)?\.?)\s*0?(\d{1,3})\b/i)?.[1] ?? value.match(/\b0?(\d{1,3})\b/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function tokenOverlap(left: string, right: string): number {
|
||||
const leftTokens = new Set(normalizeTokens(left));
|
||||
const rightTokens = new Set(normalizeTokens(right));
|
||||
if (!leftTokens.size || !rightTokens.size) return 0;
|
||||
return [...leftTokens].filter((token) => rightTokens.has(token)).length / leftTokens.size;
|
||||
}
|
||||
|
||||
function normalizeTokens(value: string): string[] {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
@ -77,3 +182,20 @@ function isbnFromIndustryIdentifiers(value: unknown, expectedIsbn13: string | nu
|
||||
const isbn10 = entries.find((entry) => entry.type === "ISBN_10")?.identifier;
|
||||
return stringValue(isbn13) ?? stringValue(isbn10);
|
||||
}
|
||||
|
||||
function isbnCandidates(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((entry) => stringValue((entry as { identifier?: unknown }).identifier)).filter((entry): entry is string => Boolean(entry));
|
||||
}
|
||||
|
||||
function coverUrl(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const links = value as Record<string, unknown>;
|
||||
return (
|
||||
stringValue(links.extraLarge) ??
|
||||
stringValue(links.large) ??
|
||||
stringValue(links.medium) ??
|
||||
stringValue(links.thumbnail) ??
|
||||
stringValue(links.smallThumbnail)
|
||||
)?.replace(/^http:/, "https:") ?? null;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
|
||||
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
|
||||
|
||||
@Injectable()
|
||||
export class LocalMetadataProvider implements MetadataProvider {
|
||||
@ -20,7 +21,7 @@ export class LocalMetadataProvider implements MetadataProvider {
|
||||
title: query.title,
|
||||
author: query.author,
|
||||
isbn: query.isbn ?? null,
|
||||
publishedDate: query.year ?? null
|
||||
publishedDate: normalizePublishedDate(query.year)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
179
apps/api/src/metadata/adapters/mangadex.provider.ts
Normal file
179
apps/api/src/metadata/adapters/mangadex.provider.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
|
||||
import { extractSeriesVolume } from "../use-cases/extract-series-volume.js";
|
||||
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
|
||||
import { providerFetch } from "./provider-fetch.js";
|
||||
|
||||
@Injectable()
|
||||
export class MangaDexProvider implements MetadataProvider {
|
||||
readonly id = "mangadex" as const;
|
||||
|
||||
async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null> {
|
||||
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, config);
|
||||
return matches[0] ?? null;
|
||||
}
|
||||
|
||||
async searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise<MetadataMatch[]> {
|
||||
const titles = mangaDexTitleQueries(query.title);
|
||||
const matches: Array<MetadataMatch & { mangaDexRank?: number }> = [];
|
||||
const seen = new Map<string, number>();
|
||||
for (const title of titles) {
|
||||
for (const match of await searchManga(title, config)) {
|
||||
const key = match.sourceId ?? `${match.title}|${match.author}`;
|
||||
const ranked = { ...match, scoreTitle: title, mangaDexRank: mangaRank(query.title, title, match) };
|
||||
const existingIndex = seen.get(key);
|
||||
if (existingIndex === undefined) {
|
||||
seen.set(key, matches.length);
|
||||
matches.push(ranked);
|
||||
continue;
|
||||
}
|
||||
if ((ranked.mangaDexRank ?? 0) > (matches[existingIndex]?.mangaDexRank ?? 0)) {
|
||||
matches[existingIndex] = ranked;
|
||||
}
|
||||
}
|
||||
}
|
||||
return rankMatches(matches);
|
||||
}
|
||||
}
|
||||
|
||||
export class MangaDexProviderError extends Error {
|
||||
constructor(
|
||||
readonly code: "rate-limit" | "http",
|
||||
readonly status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "MangaDexProviderError";
|
||||
}
|
||||
}
|
||||
|
||||
async function searchManga(title: string, _config: MetadataProviderConfig): Promise<MetadataMatch[]> {
|
||||
const url = new URL("https://api.mangadex.org/manga");
|
||||
url.searchParams.set("title", title);
|
||||
url.searchParams.set("limit", "10");
|
||||
url.searchParams.set("includes[]", "cover_art");
|
||||
url.searchParams.append("includes[]", "author");
|
||||
url.searchParams.append("includes[]", "artist");
|
||||
url.searchParams.set("contentRating[]", "safe");
|
||||
url.searchParams.append("contentRating[]", "suggestive");
|
||||
let response = await providerFetch("mangadex", url, {
|
||||
headers: { "User-Agent": "ReadaBook/0.1 self-hosted metadata provider (MangaDex)" },
|
||||
timeoutMs: 5000
|
||||
});
|
||||
if (response.status === 429) {
|
||||
await sleep(retryDelayMs(response));
|
||||
response = await providerFetch("mangadex", url, {
|
||||
headers: { "User-Agent": "ReadaBook/0.1 self-hosted metadata provider (MangaDex)" },
|
||||
timeoutMs: 5000
|
||||
});
|
||||
}
|
||||
const data = (await parseMangaDexResponse(response)) as { data?: Array<Record<string, unknown>> };
|
||||
return (data.data ?? []).map(mangaToMatch);
|
||||
}
|
||||
|
||||
function retryDelayMs(response: Response): number {
|
||||
const retryAfter = Number(response.headers.get("Retry-After"));
|
||||
return Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1000, 2000) : 250;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function parseMangaDexResponse(response: Response): Promise<unknown> {
|
||||
const data = (await response.json().catch(() => ({}))) as { errors?: Array<{ detail?: string; title?: string }> };
|
||||
if (response.ok) return data;
|
||||
const message = data.errors?.map((error) => error.detail ?? error.title).filter(Boolean).join("; ") || `MangaDex HTTP ${response.status}`;
|
||||
if (response.status === 429) throw new MangaDexProviderError("rate-limit", response.status, message);
|
||||
throw new MangaDexProviderError("http", response.status, message);
|
||||
}
|
||||
|
||||
function mangaToMatch(manga: Record<string, unknown>): MetadataMatch & { mangaDexRank?: number } {
|
||||
const id = stringValue(manga.id);
|
||||
const attributes = objectValue(manga.attributes);
|
||||
const relationships = Array.isArray(manga.relationships) ? (manga.relationships as Array<Record<string, unknown>>) : [];
|
||||
const cover = relationships.find((entry) => entry.type === "cover_art");
|
||||
const coverFile = stringValue(objectValue(cover?.attributes).fileName);
|
||||
return {
|
||||
title: localizedText(attributes.title) ?? undefined,
|
||||
author: relationshipNames(relationships),
|
||||
description: localizedText(attributes.description),
|
||||
publishedDate: normalizePublishedDate(stringValue(attributes.year)),
|
||||
language: stringValue(attributes.originalLanguage),
|
||||
coverUrl: id && coverFile ? `https://uploads.mangadex.org/covers/${id}/${coverFile}.512.jpg` : null,
|
||||
sourceId: id
|
||||
};
|
||||
}
|
||||
|
||||
function mangaDexTitleQueries(title: string): string[] {
|
||||
const cleaned = extractSeriesVolume(title).seriesTitle;
|
||||
return [cleaned, ...mangaDexTitleAliases(cleaned)].filter(
|
||||
(value, index, values): value is string => Boolean(value) && values.indexOf(value) === index
|
||||
);
|
||||
}
|
||||
|
||||
function mangaDexTitleAliases(title: string): string[] {
|
||||
const normalized = normalizeTitle(title);
|
||||
if (normalized === "demon slayer school days") return ["Demon Slayer Kimetsu Academy", "Kimetsu Academy"];
|
||||
return [];
|
||||
}
|
||||
|
||||
function mangaRank(originalTitle: string, searchedTitle: string, match: MetadataMatch): number {
|
||||
const expectedVolume = volumeNumber(originalTitle);
|
||||
let rank = tokenOverlap(searchedTitle, match.title ?? "") * 10;
|
||||
if (expectedVolume) {
|
||||
const candidateVolume = volumeNumber(match.title ?? "");
|
||||
rank += candidateVolume === expectedVolume ? 4 : candidateVolume ? -2 : 0;
|
||||
}
|
||||
if (match.coverUrl) rank += 1;
|
||||
if (match.description) rank += 1;
|
||||
return rank;
|
||||
}
|
||||
|
||||
function rankMatches(matches: Array<MetadataMatch & { mangaDexRank?: number }>): MetadataMatch[] {
|
||||
return [...matches].sort((left, right) => (right.mangaDexRank ?? 0) - (left.mangaDexRank ?? 0)).map(({ mangaDexRank: _rank, ...match }) => match);
|
||||
}
|
||||
|
||||
function volumeNumber(value: string): string | null {
|
||||
return value.match(/\b(?:T|tome|vol(?:ume)?\.?)\s*0?(\d{1,3})\b/i)?.[1] ?? value.match(/\b0?(\d{1,3})\b/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function tokenOverlap(left: string, right: string): number {
|
||||
const leftTokens = new Set(normalizeTitle(left).split(" ").filter(Boolean));
|
||||
const rightTokens = new Set(normalizeTitle(right).split(" ").filter(Boolean));
|
||||
if (!leftTokens.size || !rightTokens.size) return 0;
|
||||
return [...leftTokens].filter((token) => rightTokens.has(token)).length / leftTokens.size;
|
||||
}
|
||||
|
||||
function normalizeTitle(value: string): string {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function localizedText(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const entries = value as Record<string, unknown>;
|
||||
return stringValue(entries.en) ?? stringValue(entries.fr) ?? Object.values(entries).map(stringValue).find(Boolean) ?? null;
|
||||
}
|
||||
|
||||
function relationshipNames(relationships: Array<Record<string, unknown>>): string | null {
|
||||
const names = relationships
|
||||
.filter((entry) => entry.type === "author" || entry.type === "artist")
|
||||
.map((entry) => stringValue(objectValue(entry.attributes).name))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
return names.length ? [...new Set(names)].join(", ") : null;
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
if (typeof value === "number") return String(value);
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
|
||||
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
|
||||
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
|
||||
import { providerFetch, providerHttpError } from "./provider-fetch.js";
|
||||
|
||||
@Injectable()
|
||||
export class OpenLibraryProvider implements MetadataProvider {
|
||||
@ -24,11 +26,11 @@ export class OpenLibraryProvider implements MetadataProvider {
|
||||
if (query.author) url.searchParams.set("author", query.author);
|
||||
if (query.year) url.searchParams.set("first_publish_year", query.year);
|
||||
url.searchParams.set("limit", "5");
|
||||
const response = await fetch(url, {
|
||||
const response = await providerFetch(this.id, url, {
|
||||
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
|
||||
signal: AbortSignal.timeout(4000)
|
||||
timeoutMs: 4000
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
if (!response.ok) throw await providerHttpError(this.id, response, `OpenLibrary HTTP ${response.status}`);
|
||||
const data = (await response.json()) as { docs?: Array<Record<string, unknown>> };
|
||||
return (data.docs ?? []).map((doc) => ({
|
||||
title: stringValue(doc.title) ?? undefined,
|
||||
@ -36,28 +38,31 @@ export class OpenLibraryProvider implements MetadataProvider {
|
||||
author: arrayJoin(doc.author_name),
|
||||
language: firstArrayValue(doc.language),
|
||||
publisher: firstArrayValue(doc.publisher),
|
||||
publishedDate: String(doc.first_publish_year ?? "") || null,
|
||||
isbn: bestIsbn(doc.isbn, query.isbn ? toIsbn13(query.isbn) : null)
|
||||
publishedDate: normalizePublishedDate(String(doc.first_publish_year ?? "") || null),
|
||||
isbn: bestIsbn(doc.isbn, query.isbn ? toIsbn13(query.isbn) : null),
|
||||
coverUrl: openLibraryCoverUrl(doc.cover_i, firstArrayValue(doc.edition_key) ?? stringValue(doc.cover_edition_key))
|
||||
}));
|
||||
}
|
||||
|
||||
private async lookupEdition(sourceId: string, expectedIsbn13: string | null): Promise<MetadataMatch | null> {
|
||||
const editionKey = sourceId.replace(/^\/?books\//, "");
|
||||
if (!editionKey) return null;
|
||||
const response = await fetch(`https://openlibrary.org/books/${encodeURIComponent(editionKey)}.json`, {
|
||||
const response = await providerFetch(this.id, `https://openlibrary.org/books/${encodeURIComponent(editionKey)}.json`, {
|
||||
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
|
||||
signal: AbortSignal.timeout(4000)
|
||||
timeoutMs: 4000
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw await providerHttpError(this.id, response, `OpenLibrary HTTP ${response.status}`);
|
||||
return this.editionToMatch((await response.json()) as Record<string, unknown>, expectedIsbn13);
|
||||
}
|
||||
|
||||
private async lookupIsbn(isbn: string, expectedIsbn13: string | null): Promise<MetadataMatch | null> {
|
||||
const response = await fetch(`https://openlibrary.org/isbn/${encodeURIComponent(isbn)}.json`, {
|
||||
const response = await providerFetch(this.id, `https://openlibrary.org/isbn/${encodeURIComponent(isbn)}.json`, {
|
||||
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
|
||||
signal: AbortSignal.timeout(4000)
|
||||
timeoutMs: 4000
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw await providerHttpError(this.id, response, `OpenLibrary HTTP ${response.status}`);
|
||||
return this.editionToMatch((await response.json()) as Record<string, unknown>, expectedIsbn13);
|
||||
}
|
||||
|
||||
@ -70,18 +75,20 @@ export class OpenLibraryProvider implements MetadataProvider {
|
||||
isbn: bestIsbn([...(asStringArray(edition.isbn_13)), ...(asStringArray(edition.isbn_10))], expectedIsbn13),
|
||||
language: languageValue(edition.languages),
|
||||
publisher: firstArrayValue(edition.publishers),
|
||||
publishedDate: stringValue(edition.publish_date)
|
||||
publishedDate: normalizePublishedDate(stringValue(edition.publish_date)),
|
||||
coverUrl: editionCoverUrl(edition)
|
||||
};
|
||||
}
|
||||
|
||||
private async lookupAuthorName(value: unknown): Promise<string | null> {
|
||||
const key = (Array.isArray(value) ? value[0] : undefined)?.key;
|
||||
if (typeof key !== "string") return null;
|
||||
const response = await fetch(`https://openlibrary.org${key}.json`, {
|
||||
const response = await providerFetch(this.id, `https://openlibrary.org${key}.json`, {
|
||||
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
|
||||
signal: AbortSignal.timeout(3000)
|
||||
timeoutMs: 3000
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw await providerHttpError(this.id, response, `OpenLibrary HTTP ${response.status}`);
|
||||
const author = (await response.json()) as Record<string, unknown>;
|
||||
return stringValue(author.name);
|
||||
}
|
||||
@ -124,3 +131,18 @@ function languageValue(value: unknown): string | null {
|
||||
const key = (Array.isArray(value) ? value[0] : undefined)?.key;
|
||||
return typeof key === "string" ? key.split("/").pop() ?? null : null;
|
||||
}
|
||||
|
||||
function openLibraryCoverUrl(coverId: unknown, editionKey: string | null): string | null {
|
||||
if (typeof coverId === "number" || typeof coverId === "string") {
|
||||
return `https://covers.openlibrary.org/b/id/${encodeURIComponent(String(coverId))}-L.jpg`;
|
||||
}
|
||||
if (editionKey) {
|
||||
return `https://covers.openlibrary.org/b/olid/${encodeURIComponent(editionKey)}-L.jpg`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function editionCoverUrl(edition: Record<string, unknown>): string | null {
|
||||
const covers = Array.isArray(edition.covers) ? edition.covers : [];
|
||||
return openLibraryCoverUrl(covers[0], stringValue(edition.key)?.split("/").pop() ?? null);
|
||||
}
|
||||
|
||||
88
apps/api/src/metadata/adapters/provider-fetch.ts
Normal file
88
apps/api/src/metadata/adapters/provider-fetch.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { MetadataProviderId } from "../metadata.types.js";
|
||||
|
||||
export type MetadataProviderFailureCode = "timeout" | "dns" | "quota" | "auth" | "http" | "network";
|
||||
|
||||
export class MetadataProviderRequestError extends Error {
|
||||
constructor(
|
||||
readonly provider: MetadataProviderId | "cover",
|
||||
readonly code: MetadataProviderFailureCode,
|
||||
readonly message: string,
|
||||
readonly status?: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "MetadataProviderRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function providerFetch(
|
||||
provider: MetadataProviderId | "cover",
|
||||
input: string | URL,
|
||||
init: RequestInit & { timeoutMs: number }
|
||||
): Promise<Response> {
|
||||
const { timeoutMs, ...requestInit } = init;
|
||||
try {
|
||||
return await fetch(input, {
|
||||
...requestInit,
|
||||
signal: requestInit.signal ?? AbortSignal.timeout(timeoutMs)
|
||||
});
|
||||
} catch (error) {
|
||||
throw classifyFetchError(provider, error, timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
export async function providerHttpError(
|
||||
provider: MetadataProviderId | "cover",
|
||||
response: Response,
|
||||
fallbackMessage: string
|
||||
): Promise<MetadataProviderRequestError> {
|
||||
const message = (await response.text().catch(() => "")) || fallbackMessage;
|
||||
if (response.status === 429) return new MetadataProviderRequestError(provider, "quota", message, response.status);
|
||||
if (response.status === 401 || response.status === 403) return new MetadataProviderRequestError(provider, "auth", message, response.status);
|
||||
return new MetadataProviderRequestError(provider, "http", message, response.status);
|
||||
}
|
||||
|
||||
export function describeMetadataProviderError(error: unknown): string {
|
||||
if (error instanceof MetadataProviderRequestError) {
|
||||
const status = error.status ? ` HTTP ${error.status}` : "";
|
||||
return `${error.code}${status}: ${error.message}`;
|
||||
}
|
||||
if (hasProviderErrorCode(error)) {
|
||||
const status = typeof error.status === "number" ? ` HTTP ${error.status}` : "";
|
||||
return `${String(error.code)}${status}: ${errorMessage(error)}`;
|
||||
}
|
||||
return errorMessage(error);
|
||||
}
|
||||
|
||||
function classifyFetchError(provider: MetadataProviderId | "cover", error: unknown, timeoutMs: number): MetadataProviderRequestError {
|
||||
const code = nestedCode(error);
|
||||
if (isTimeoutError(error)) {
|
||||
return new MetadataProviderRequestError(provider, "timeout", `request timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
if (code === "EAI_AGAIN" || code === "ENOTFOUND") {
|
||||
return new MetadataProviderRequestError(provider, "dns", code);
|
||||
}
|
||||
return new MetadataProviderRequestError(provider, "network", errorMessage(error));
|
||||
}
|
||||
|
||||
function isTimeoutError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError") ||
|
||||
error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")
|
||||
);
|
||||
}
|
||||
|
||||
function nestedCode(error: unknown): string | null {
|
||||
if (!error || typeof error !== "object") return null;
|
||||
const direct = "code" in error && typeof error.code === "string" ? error.code : null;
|
||||
if (direct) return direct;
|
||||
const cause = "cause" in error ? error.cause : null;
|
||||
return cause && typeof cause === "object" && "code" in cause && typeof cause.code === "string" ? cause.code : null;
|
||||
}
|
||||
|
||||
function hasProviderErrorCode(error: unknown): error is { code: string; status?: number; message?: string } {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && typeof error.code === "string");
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
43
apps/api/src/metadata/extract-series-volume.test.ts
Normal file
43
apps/api/src/metadata/extract-series-volume.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractSeriesVolume, normalizeSeriesTitle } from "./use-cases/extract-series-volume.js";
|
||||
|
||||
describe("extractSeriesVolume", () => {
|
||||
it.each([
|
||||
["Daredevil 001.cbz", "Daredevil", 1, "001"],
|
||||
["Daredevil 002.cbz", "Daredevil", 2, "002"],
|
||||
["Daredevil - 001[Sebmov].cbz", "Daredevil", 1, "001"],
|
||||
["DareDevil - 007[Fennlhor].cbz", "DareDevil", 7, "007"],
|
||||
["Solo Leveling T03.cbz", "Solo Leveling", 3, "T03"],
|
||||
["Solo Leveling 003.cbz", "Solo Leveling", 3, "003"],
|
||||
["Solo Leveling Tome 3.cbz", "Solo Leveling", 3, "Tome 3"],
|
||||
["Solo Leveling Vol. 3.cbz", "Solo Leveling", 3, "Vol 3"],
|
||||
["Daredevil Issue 6.cbz", "Daredevil", 6, "Issue 6"],
|
||||
["Daredevil #6.cbz", "Daredevil", 6, "#6"],
|
||||
["Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz.cbz", "Eyeshield 21", 1, "T01"],
|
||||
["Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz", "Dragon Ball SD", 1, "T01"],
|
||||
["Demon.Slayer.School.Days.T01.FRENCH.CBZ.eBook-ebdz.cbz", "Demon Slayer School Days", 1, "T01"]
|
||||
])("extracts series and volume from %s", (fileName, seriesTitle, volumeNumber, volumeLabel) => {
|
||||
expect(extractSeriesVolume(seriesTitle, `/books/${fileName}`)).toMatchObject({
|
||||
seriesTitle,
|
||||
normalizedSeriesTitle: normalizeSeriesTitle(seriesTitle),
|
||||
volumeNumber,
|
||||
volumeLabel
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps numeric title components that are not explicit volume markers", () => {
|
||||
expect(extractSeriesVolume("Eyeshield 21")).toMatchObject({
|
||||
seriesTitle: "Eyeshield 21",
|
||||
normalizedSeriesTitle: "eyeshield 21",
|
||||
volumeNumber: null,
|
||||
volumeLabel: null
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fuzzy-merge distinct normalized series titles", () => {
|
||||
expect(normalizeSeriesTitle("Dragon Ball SD")).toBe("dragon ball sd");
|
||||
expect(normalizeSeriesTitle("Dragon Ball")).toBe("dragon ball");
|
||||
expect(normalizeSeriesTitle("Lord of the Mysteries")).toBe("lord of the mysteries");
|
||||
expect(normalizeSeriesTitle("The Lord of the Rings")).toBe("the lord of the rings");
|
||||
});
|
||||
});
|
||||
@ -38,6 +38,76 @@ describe("metadata match scoring", () => {
|
||||
);
|
||||
|
||||
expect(best?.match.title).toBe("Harry Potter et le Prince de sang-mêlé");
|
||||
expect(best?.score).toBeGreaterThan(0.7);
|
||||
expect(best?.score).toBeGreaterThan(70);
|
||||
});
|
||||
|
||||
it("scores titles, authors and dates with the contract weights", () => {
|
||||
const scorer = new ScoreMetadataMatch();
|
||||
const result = scorer.details(
|
||||
{ title: "The Harry Potter et le prince de sang mêlé: édition collector", author: "J. K. Rowling", year: "2005" },
|
||||
{
|
||||
title: "Harry Potter et le prince de sang-mêlé",
|
||||
author: "J.K. Rowling",
|
||||
publishedDate: "2006"
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.titleScore).toBe(100);
|
||||
expect(result.authorScore).toBe(30);
|
||||
expect(result.dateScore).toBe(5);
|
||||
expect(result.score).toBe(96);
|
||||
});
|
||||
|
||||
it("uses exact ISBN matches before weaker title-only candidates", () => {
|
||||
const best = new ScoreMetadataMatch().best(
|
||||
{ title: "Daredevil", author: null, isbn: "9782809476255" },
|
||||
[
|
||||
{
|
||||
title: "Daredevil",
|
||||
author: "Rosemary Carter",
|
||||
isbn: "9780373105601"
|
||||
},
|
||||
{
|
||||
title: "Daredevil by Chip Zdarsky",
|
||||
author: "Chip Zdarsky",
|
||||
isbn: "9782809476255"
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
expect(best?.match.author).toBe("Chip Zdarsky");
|
||||
expect(best?.isbnMatch).toBe(true);
|
||||
});
|
||||
|
||||
it("scores unrelated serialized or audiobook candidates from title/author/date only", () => {
|
||||
const scorer = new ScoreMetadataMatch();
|
||||
const query = { title: "Harry Potter et le prince de sang mêlé", author: "J. K. Rowling" };
|
||||
const french = scorer.score(query, {
|
||||
title: "Harry Potter et le prince de sang-mêlé",
|
||||
author: "J. K. Rowling",
|
||||
isbn: "9782070577644"
|
||||
});
|
||||
const koreanVolume = scorer.score(query, {
|
||||
title: "Harry Potter et le prince de sang-mêlé - Volume 1",
|
||||
author: "J. K. Rowling",
|
||||
publisher: "문학수첩",
|
||||
isbn: "9791193790724"
|
||||
});
|
||||
|
||||
expect(french).toBeGreaterThan(80);
|
||||
expect(koreanVolume).toBeLessThan(french);
|
||||
});
|
||||
|
||||
it("does not apply legacy audiobook penalties outside the contract", () => {
|
||||
const scorer = new ScoreMetadataMatch();
|
||||
const query = { title: "Harry Potter et le prince de sang mêlé", author: "J. K. Rowling" };
|
||||
|
||||
expect(
|
||||
scorer.score(query, {
|
||||
title: "Harry Potter Et Le Prince De Sang-mêlé Livre Audio",
|
||||
author: "J. K. Rowling",
|
||||
isbn: "9782075105170"
|
||||
})
|
||||
).toBeGreaterThan(80);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { BnfProvider } from "./adapters/bnf.provider.js";
|
||||
import { GoogleBooksProvider } from "./adapters/google-books.provider.js";
|
||||
import { ComicVineProvider } from "./adapters/comic-vine.provider.js";
|
||||
import { GoogleBooksProvider, GoogleBooksProviderError } from "./adapters/google-books.provider.js";
|
||||
import { MangaDexProvider } from "./adapters/mangadex.provider.js";
|
||||
import { OpenLibraryProvider } from "./adapters/open-library.provider.js";
|
||||
import { MetadataProviderRequestError } from "./adapters/provider-fetch.js";
|
||||
import { MetadataLookup, MetadataProviderConfig } from "./metadata.types.js";
|
||||
|
||||
const lookup: MetadataLookup = {
|
||||
@ -58,14 +61,210 @@ describe("metadata providers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("treats Google Books quota exhaustion as a non-blocking miss", async () => {
|
||||
it("reports Google Books quota exhaustion explicitly", async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ error: { code: 429, status: "RESOURCE_EXHAUSTED" } }, 429));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await new GoogleBooksProvider().lookup(lookup, { ...config, provider: "googlebooks" });
|
||||
await expect(new GoogleBooksProvider().lookup(lookup, { ...config, provider: "googlebooks" })).rejects.toMatchObject({
|
||||
code: "quota",
|
||||
status: 429
|
||||
} satisfies Partial<GoogleBooksProviderError>);
|
||||
|
||||
expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toContain("q=isbn%3A9782070612376");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("classifies provider DNS failures explicitly", async () => {
|
||||
const error = new TypeError("fetch failed") as Error & { cause?: { code: string } };
|
||||
error.cause = { code: "EAI_AGAIN" };
|
||||
vi.stubGlobal("fetch", vi.fn(async () => Promise.reject(error)));
|
||||
|
||||
await expect(new OpenLibraryProvider().searchByMetadata({ title: "Daredevil", author: null }, config)).rejects.toMatchObject({
|
||||
code: "dns",
|
||||
message: "EAI_AGAIN"
|
||||
} satisfies Partial<MetadataProviderRequestError>);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Demon.Slayer.School.Days.T01.FRENCH.CBZ.eBook-ebdz", "Demon Slayer School Days 1"],
|
||||
["Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+", "Dragon Ball SD 1"],
|
||||
["Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz", "Eyeshield 21 1"],
|
||||
["Solo Leveling T03", "Solo Leveling 3"]
|
||||
])("cleans noisy Google Books title queries for %s", async (title, expectedCleanTitle) => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ totalItems: 0, items: [] }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await new GoogleBooksProvider().searchByMetadata({ title, author: null }, { ...config, provider: "googlebooks" });
|
||||
const queries = fetchMock.mock.calls.map((call) => new URL(String((call as unknown[])[0])).searchParams.get("q") ?? "");
|
||||
|
||||
expect(queries[0]).toBe(`intitle:"${expectedCleanTitle}"`);
|
||||
expect(queries.join(" ")).not.toMatch(/\b(FRENCH|CBZ|eBook|ebdz|Paprika)\b/i);
|
||||
});
|
||||
|
||||
it("sorts Google Books results by matching manga volume instead of taking the first item", async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
totalItems: 2,
|
||||
items: [
|
||||
{ id: "volume-2", volumeInfo: { title: "Solo Leveling, Vol. 2", authors: ["Chugong"], publishedDate: "2021" } },
|
||||
{ id: "volume-3", volumeInfo: { title: "Solo Leveling, Vol. 3", authors: ["Chugong"], publishedDate: "2021" } }
|
||||
]
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const results = await new GoogleBooksProvider().searchByMetadata(
|
||||
{ title: "Solo Leveling T03", author: null },
|
||||
{ ...config, provider: "googlebooks" }
|
||||
);
|
||||
|
||||
expect(results[0]).toMatchObject({ title: "Solo Leveling, Vol. 3", sourceId: "volume-3" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Solo Leveling T03", "Solo Leveling"],
|
||||
["Solo Leveling 003", "Solo Leveling"],
|
||||
["Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz", "Eyeshield 21"],
|
||||
["Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+", "Dragon Ball SD"]
|
||||
])("queries MangaDex with the cleaned series title for %s", async (title, expectedQuery) => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ data: [] }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await new MangaDexProvider().searchByMetadata({ title, author: null }, { ...config, provider: "mangadex" });
|
||||
const firstUrl = new URL(String((fetchMock.mock.calls[0] as unknown[])[0]));
|
||||
|
||||
expect(firstUrl.searchParams.get("title")).toBe(expectedQuery);
|
||||
});
|
||||
|
||||
it("queries MangaDex aliases and maps cover_art to a cover URL", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ data: [] }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
data: [
|
||||
{
|
||||
id: "manga-1",
|
||||
attributes: {
|
||||
title: { en: "Demon Slayer: Kimetsu Academy" },
|
||||
description: { en: "School spin-off." },
|
||||
year: 2021,
|
||||
originalLanguage: "ja"
|
||||
},
|
||||
relationships: [
|
||||
{ type: "cover_art", attributes: { fileName: "cover.jpg" } },
|
||||
{ type: "author", attributes: { name: "Natsuki Hokami" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValue(jsonResponse({ data: [] }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const results = await new MangaDexProvider().searchByMetadata(
|
||||
{ title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ.eBook-ebdz", author: null },
|
||||
{ ...config, provider: "mangadex" }
|
||||
);
|
||||
const firstUrl = new URL(String((fetchMock.mock.calls[0] as unknown[])[0]));
|
||||
const secondUrl = new URL(String((fetchMock.mock.calls[1] as unknown[])[0]));
|
||||
|
||||
expect(firstUrl.searchParams.get("title")).toBe("Demon Slayer School Days");
|
||||
expect(secondUrl.searchParams.get("title")).toBe("Demon Slayer Kimetsu Academy");
|
||||
expect(results[0]).toMatchObject({
|
||||
title: "Demon Slayer: Kimetsu Academy",
|
||||
scoreTitle: "Demon Slayer Kimetsu Academy",
|
||||
author: "Natsuki Hokami",
|
||||
publishedDate: "2021",
|
||||
coverUrl: "https://uploads.mangadex.org/covers/manga-1/cover.jpg.512.jpg"
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Dragon Ball SD ahead of Dragon Ball for MangaDex matches", async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
data: [
|
||||
{
|
||||
id: "dragon-ball",
|
||||
attributes: { title: { en: "Dragon Ball" }, description: { en: "Original series." }, year: 1984, originalLanguage: "ja" },
|
||||
relationships: []
|
||||
},
|
||||
{
|
||||
id: "dragon-ball-sd",
|
||||
attributes: { title: { en: "Dragon Ball SD" }, description: { en: "SD spin-off." }, year: 2010, originalLanguage: "ja" },
|
||||
relationships: []
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const results = await new MangaDexProvider().searchByMetadata(
|
||||
{ title: "Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+", author: null },
|
||||
{ ...config, provider: "mangadex" }
|
||||
);
|
||||
|
||||
expect(results[0]).toMatchObject({ title: "Dragon Ball SD", sourceId: "dragon-ball-sd" });
|
||||
});
|
||||
|
||||
it("reports MangaDex rate limits explicitly", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ errors: [{ detail: "Too many requests" }] }, 429)));
|
||||
|
||||
await expect(
|
||||
new MangaDexProvider().searchByMetadata({ title: "Solo Leveling T03", author: null }, { ...config, provider: "mangadex" })
|
||||
).rejects.toMatchObject({ code: "rate-limit", status: 429 });
|
||||
});
|
||||
|
||||
it("requires a Comic Vine API key before querying", async () => {
|
||||
await expect(
|
||||
new ComicVineProvider().searchByMetadata({ title: "Wolverine Origin", author: null }, { ...config, provider: "comicvine", apiKey: null })
|
||||
).rejects.toMatchObject({ code: "missing-key" });
|
||||
});
|
||||
|
||||
it("queries Comic Vine volumes/issues and cleans HTML descriptions", async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
status_code: 1,
|
||||
results: [
|
||||
{
|
||||
id: 123,
|
||||
name: "Wolverine: The Origin",
|
||||
description: "<p>Origin story & family secrets.</p>",
|
||||
start_year: "2001",
|
||||
image: { super_url: "https://comicvine.gamespot.com/a/uploads/scale_large/origin.jpg" },
|
||||
publisher: { name: "Marvel" }
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const results = await new ComicVineProvider().searchByMetadata(
|
||||
{ title: "Comics.Fr.Wolverine.Origin.by.AleK.(emuleCenter.net)", author: null },
|
||||
{ ...config, provider: "comicvine", apiKey: "cv-key" }
|
||||
);
|
||||
const firstUrl = new URL(String((fetchMock.mock.calls[0] as unknown[])[0]));
|
||||
|
||||
expect(firstUrl.searchParams.get("resources")).toBe("volume");
|
||||
expect(firstUrl.searchParams.get("query")).toBe("Comics Fr Wolverine Origin by AleK");
|
||||
expect(results[0]).toMatchObject({
|
||||
title: "Wolverine: The Origin",
|
||||
description: "Origin story & family secrets.",
|
||||
publishedDate: "2001",
|
||||
publisher: "Marvel",
|
||||
coverUrl: "https://comicvine.gamespot.com/a/uploads/scale_large/origin.jpg"
|
||||
});
|
||||
});
|
||||
|
||||
it("reports Comic Vine invalid keys and rate limits explicitly", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ status_code: 101, error: "Invalid API Key" })));
|
||||
await expect(
|
||||
new ComicVineProvider().searchByMetadata({ title: "Daredevil", author: null }, { ...config, provider: "comicvine", apiKey: "bad" })
|
||||
).rejects.toMatchObject({ code: "invalid-key" });
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ error: "Rate limited" }, 429)));
|
||||
await expect(
|
||||
new ComicVineProvider().searchByMetadata({ title: "Daredevil", author: null }, { ...config, provider: "comicvine", apiKey: "ok" })
|
||||
).rejects.toMatchObject({ code: "rate-limit", status: 429 });
|
||||
});
|
||||
|
||||
it("queries OpenLibrary by local metadata when ISBN is missing", async () => {
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { DatabaseModule } from "../database/database.module.js";
|
||||
import { BnfProvider } from "./adapters/bnf.provider.js";
|
||||
import { ComicVineProvider } from "./adapters/comic-vine.provider.js";
|
||||
import { GoogleBooksProvider } from "./adapters/google-books.provider.js";
|
||||
import { LocalMetadataProvider } from "./adapters/local.provider.js";
|
||||
import { MangaDexProvider } from "./adapters/mangadex.provider.js";
|
||||
import { OpenLibraryProvider } from "./adapters/open-library.provider.js";
|
||||
import { MetadataService } from "./metadata.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
providers: [MetadataService, LocalMetadataProvider, OpenLibraryProvider, GoogleBooksProvider, BnfProvider],
|
||||
providers: [MetadataService, LocalMetadataProvider, OpenLibraryProvider, GoogleBooksProvider, BnfProvider, MangaDexProvider, ComicVineProvider],
|
||||
exports: [MetadataService]
|
||||
})
|
||||
export class MetadataModule {}
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { books, libraries } from "../database/schema.js";
|
||||
import { BookMetadata } from "../scanner/metadata.js";
|
||||
import { MetadataProviderRequestError } from "./adapters/provider-fetch.js";
|
||||
import { MetadataService } from "./metadata.service.js";
|
||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "./metadata.types.js";
|
||||
|
||||
@ -52,7 +55,9 @@ describe("MetadataService", () => {
|
||||
localProvider as never,
|
||||
openLibraryProvider as never,
|
||||
providerStub("googlebooks") as never,
|
||||
providerStub("bnf") as never
|
||||
providerStub("bnf") as never,
|
||||
providerStub("mangadex") as never,
|
||||
providerStub("comicvine") as never
|
||||
);
|
||||
const localMetadata: BookMetadata = {
|
||||
title: "Harry Potter et le Prince de Sang Mele",
|
||||
@ -73,7 +78,7 @@ describe("MetadataService", () => {
|
||||
expect(openLibraryLookup).toHaveBeenCalledOnce();
|
||||
expect(openLibraryLookup.mock.calls[0]?.[0].identifiers.isbn13).toBe("9782070612383");
|
||||
expect(result).toMatchObject({
|
||||
title: "Harry Potter et le Prince de sang-mêlé",
|
||||
title: "Harry Potter et le Prince de Sang Mele",
|
||||
isbn: "9782070612383",
|
||||
isbn13: "9782070612383",
|
||||
description: "Harry Potter découvre l'héritage du Prince de Sang-Mêlé."
|
||||
@ -82,6 +87,64 @@ describe("MetadataService", () => {
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("continues enrichment after a provider DNS failure and logs the failure class", async () => {
|
||||
const database = createDatabase();
|
||||
database.sqlite.prepare("UPDATE metadata_source_config SET enabled = 1 WHERE provider = 'googlebooks'").run();
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const openLibraryProvider: MetadataProvider = {
|
||||
id: "openlibrary",
|
||||
lookup: async () => null,
|
||||
searchByMetadata: async () => {
|
||||
throw new MetadataProviderRequestError("openlibrary", "dns", "EAI_AGAIN");
|
||||
}
|
||||
};
|
||||
const googleProvider: MetadataProvider = {
|
||||
id: "googlebooks",
|
||||
lookup: async () => null,
|
||||
searchByMetadata: async () => [
|
||||
{
|
||||
title: "Daredevil",
|
||||
author: "Roy Thomas",
|
||||
description: "Daredevil keeps moving even when another provider is unreachable.",
|
||||
publishedDate: "2019"
|
||||
}
|
||||
]
|
||||
};
|
||||
const service = new MetadataService(
|
||||
database,
|
||||
providerStub("local") as never,
|
||||
openLibraryProvider as never,
|
||||
googleProvider as never,
|
||||
providerStub("bnf") as never,
|
||||
providerStub("mangadex") as never,
|
||||
providerStub("comicvine") as never
|
||||
);
|
||||
|
||||
const result = await service.enrichMetadata(
|
||||
{
|
||||
title: "Daredevil",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
coverPath: null
|
||||
},
|
||||
"/library/Daredevil.cbz",
|
||||
{ remote: true }
|
||||
);
|
||||
|
||||
expect(warn).toHaveBeenCalledWith('[metadata] Provider openlibrary failed for "Daredevil": dns: EAI_AGAIN');
|
||||
expect(result).toMatchObject({
|
||||
title: "Daredevil",
|
||||
author: "Roy Thomas",
|
||||
description: "Daredevil keeps moving even when another provider is unreachable."
|
||||
});
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("looks up details from a title search hit even when the hit has no ISBN", async () => {
|
||||
const database = createDatabase();
|
||||
const localProvider = providerStub("local");
|
||||
@ -112,7 +175,9 @@ describe("MetadataService", () => {
|
||||
localProvider as never,
|
||||
openLibraryProvider as never,
|
||||
providerStub("googlebooks") as never,
|
||||
providerStub("bnf") as never
|
||||
providerStub("bnf") as never,
|
||||
providerStub("mangadex") as never,
|
||||
providerStub("comicvine") as never
|
||||
);
|
||||
const localMetadata: BookMetadata = {
|
||||
title: "Harry Potter et le prince de sang mele",
|
||||
@ -133,7 +198,7 @@ describe("MetadataService", () => {
|
||||
expect(openLibraryLookup).toHaveBeenCalledOnce();
|
||||
expect(openLibraryLookup.mock.calls[0]?.[0].sourceId).toBe("OL24333986M");
|
||||
expect(result).toMatchObject({
|
||||
title: "Harry Potter et le prince de sang-mêlé",
|
||||
title: "Harry Potter et le prince de sang mele",
|
||||
author: "J. K. Rowling",
|
||||
isbn: "9782070612383",
|
||||
isbn13: "9782070612383",
|
||||
@ -143,7 +208,61 @@ describe("MetadataService", () => {
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("replaces an ambiguous title-only identification when a later provider supplies a described record", async () => {
|
||||
it.runIf(canLoadBetterSqlite())("backfills a missing ISBN lookup description from a high-confidence title search", async () => {
|
||||
const database = createDatabase();
|
||||
const openLibraryProvider: MetadataProvider = {
|
||||
id: "openlibrary",
|
||||
lookup: async () => ({
|
||||
title: "Harry Potter et la coupe de feu",
|
||||
author: "J. K. Rowling",
|
||||
isbn: "9782070624553",
|
||||
publisher: "Gallimard",
|
||||
publishedDate: "2016"
|
||||
}),
|
||||
searchByMetadata: async () => [
|
||||
{
|
||||
title: "Harry Potter et la coupe de feu",
|
||||
author: "J. K. Rowling",
|
||||
isbn: "9782070619207",
|
||||
description: "Harry est invité à assister à la Coupe du monde de Quidditch."
|
||||
}
|
||||
]
|
||||
};
|
||||
const service = new MetadataService(
|
||||
database,
|
||||
providerStub("local") as never,
|
||||
openLibraryProvider as never,
|
||||
providerStub("googlebooks") as never,
|
||||
providerStub("bnf") as never,
|
||||
providerStub("mangadex") as never,
|
||||
providerStub("comicvine") as never
|
||||
);
|
||||
|
||||
const result = await service.enrichMetadata(
|
||||
{
|
||||
title: "Harry Potter et la coupe de feu",
|
||||
author: "J. K. Rowling",
|
||||
description: null,
|
||||
isbn: "9782070624553",
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
coverPath: "/covers/local.jpg"
|
||||
},
|
||||
"/library/Harry Potter et la coupe de feu.epub",
|
||||
{ remote: true }
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isbn: "9782070624553",
|
||||
description: "Harry est invité à assister à la Coupe du monde de Quidditch.",
|
||||
coverPath: "/covers/local.jpg"
|
||||
});
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("uses provider priority as the tie-breaker for high-confidence title matches", async () => {
|
||||
const database = createDatabase();
|
||||
database.sqlite.prepare("UPDATE metadata_source_config SET enabled = 1 WHERE provider = 'bnf'").run();
|
||||
const openLibraryProvider: MetadataProvider = {
|
||||
@ -179,7 +298,9 @@ describe("MetadataService", () => {
|
||||
providerStub("local") as never,
|
||||
openLibraryProvider as never,
|
||||
providerStub("googlebooks") as never,
|
||||
bnfProvider as never
|
||||
bnfProvider as never,
|
||||
providerStub("mangadex") as never,
|
||||
providerStub("comicvine") as never
|
||||
);
|
||||
const localMetadata: BookMetadata = {
|
||||
title: "Daredevil",
|
||||
@ -196,14 +317,583 @@ describe("MetadataService", () => {
|
||||
|
||||
expect(result).toMatchObject({
|
||||
title: "Daredevil",
|
||||
author: "scénario, Roy Thomas, Gary Friedrich",
|
||||
isbn: "9782809476255",
|
||||
isbn13: "9782809476255",
|
||||
author: "Rosemary Carter",
|
||||
isbn: "9780373105601",
|
||||
isbn13: "9780373105601",
|
||||
description: "Daredevil affronte l'Homme aux échasses."
|
||||
});
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("re-enriches from stored local hints instead of a previously failed remote ISBN", async () => {
|
||||
const database = createDatabase();
|
||||
const now = database.now();
|
||||
const library = database.db
|
||||
.insert(libraries)
|
||||
.values({ name: "Comics", path: "/library", enabled: true, createdAt: now, updatedAt: now })
|
||||
.returning()
|
||||
.get();
|
||||
const localMetadataJson = JSON.stringify({
|
||||
title: "Daredevil",
|
||||
author: null,
|
||||
year: null,
|
||||
isbn: null,
|
||||
fileTitle: "Daredevil",
|
||||
raw: { title: "Daredevil", author: null, publishedDate: null, fileName: "Daredevil" }
|
||||
});
|
||||
const book = database.db
|
||||
.insert(books)
|
||||
.values({
|
||||
libraryId: library.id,
|
||||
title: "Daredevil",
|
||||
author: "Rosemary Carter",
|
||||
description: null,
|
||||
isbn: "9780373105601",
|
||||
isbn13: "9780373105601",
|
||||
identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }),
|
||||
localMetadataJson,
|
||||
language: null,
|
||||
publisher: "Harlequin Books",
|
||||
publishedDate: "1982",
|
||||
format: "cbz",
|
||||
filePath: "/library/Daredevil.cbz",
|
||||
coverPath: "/covers/daredevil.jpg",
|
||||
scanStatus: "succeeded",
|
||||
enrichmentStatus: "failed",
|
||||
fileSize: 42,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise<MetadataMatch | null>>(async () => null);
|
||||
const bnfSearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise<MetadataMatch[]>>(async () => [
|
||||
{
|
||||
title: "Daredevil",
|
||||
author: "scénario, Roy Thomas, Gary Friedrich",
|
||||
isbn: "9782809476255",
|
||||
description: "Daredevil affronte l'Homme aux échasses.",
|
||||
publisher: "Panini comics",
|
||||
publishedDate: "2019"
|
||||
}
|
||||
]);
|
||||
database.sqlite.prepare("UPDATE metadata_source_config SET enabled = 1 WHERE provider = 'bnf'").run();
|
||||
const service = new MetadataService(
|
||||
database,
|
||||
providerStub("local") as never,
|
||||
{ id: "openlibrary", lookup: openLibraryLookup, searchByMetadata: async () => [] } as never,
|
||||
providerStub("googlebooks") as never,
|
||||
{ id: "bnf", lookup: async () => null, searchByMetadata: bnfSearch } as never,
|
||||
providerStub("mangadex") as never,
|
||||
providerStub("comicvine") as never
|
||||
);
|
||||
|
||||
const result = await service.enrichBook(book.id);
|
||||
|
||||
expect(openLibraryLookup).not.toHaveBeenCalled();
|
||||
expect(bnfSearch.mock.calls[0]?.[0].title).toBe("Daredevil");
|
||||
expect(result).toMatchObject({
|
||||
author: "Rosemary Carter",
|
||||
isbn: "9780373105601",
|
||||
isbn13: "9780373105601",
|
||||
publisher: "Harlequin Books",
|
||||
coverPath: "/covers/daredevil.jpg"
|
||||
});
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("stores provider covers as local bytes and exposes field provenance with metadata status", async () => {
|
||||
const database = createDatabase();
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/jpeg" }),
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3, 4]).buffer
|
||||
} as Response);
|
||||
const openLibraryProvider: MetadataProvider = {
|
||||
id: "openlibrary",
|
||||
lookup: async () => null,
|
||||
searchByMetadata: async () => [
|
||||
{
|
||||
title: "Daredevil",
|
||||
author: "Roy Thomas",
|
||||
description: "Daredevil affronte une nouvelle menace.",
|
||||
isbn: "9782809476255",
|
||||
coverUrl: "https://covers.openlibrary.org/b/id/123-L.jpg"
|
||||
}
|
||||
]
|
||||
};
|
||||
const service = new MetadataService(
|
||||
database,
|
||||
providerStub("local") as never,
|
||||
openLibraryProvider as never,
|
||||
providerStub("googlebooks") as never,
|
||||
providerStub("bnf") as never,
|
||||
providerStub("mangadex") as never,
|
||||
providerStub("comicvine") as never
|
||||
);
|
||||
|
||||
const result = await service.enrichMetadata(
|
||||
{
|
||||
title: "Daredevil",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
coverPath: null
|
||||
},
|
||||
"/library/Daredevil.cbz",
|
||||
{ remote: true }
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://covers.openlibrary.org/b/id/123-L.jpg", expect.any(Object));
|
||||
expect(result.coverPath).toMatch(/storage\/covers\/.+\.jpg$/);
|
||||
expect(result.metadataStatus).toBe("enriched");
|
||||
expect(JSON.parse(result.metadataProvenanceJson)).toMatchObject({
|
||||
title: "local",
|
||||
author: "openlibrary",
|
||||
description: "openlibrary",
|
||||
coverPath: "openlibrary"
|
||||
});
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("retrofits a local cover for an existing book during metadata enrichment", async () => {
|
||||
const database = createDatabase();
|
||||
mkdirSync(database.config.storageDir, { recursive: true });
|
||||
const filePath = join(database.config.storageDir, "Demon.Slayer.School.Days.T01.FRENCH.CBZ");
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
||||
zip.writeZip(filePath);
|
||||
const now = database.now();
|
||||
const library = database.db
|
||||
.insert(libraries)
|
||||
.values({ name: "Comics", path: database.config.storageDir, enabled: true, createdAt: now, updatedAt: now })
|
||||
.returning()
|
||||
.get();
|
||||
const book = database.db
|
||||
.insert(books)
|
||||
.values({
|
||||
libraryId: library.id,
|
||||
title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }),
|
||||
localMetadataJson: JSON.stringify({
|
||||
title: "Demon Slayer School Days T01 FRENCH",
|
||||
author: null,
|
||||
year: null,
|
||||
isbn: null,
|
||||
fileTitle: "Demon Slayer School Days T01 FRENCH",
|
||||
raw: { title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz", author: null, publishedDate: null, fileName: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz" }
|
||||
}),
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
format: "cbz",
|
||||
filePath,
|
||||
coverPath: null,
|
||||
metadataStatus: "none",
|
||||
metadataProvenanceJson: JSON.stringify({ title: "local" }),
|
||||
scanStatus: "succeeded",
|
||||
enrichmentStatus: "succeeded",
|
||||
fileSize: 42,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
const service = new MetadataService(
|
||||
database,
|
||||
providerStub("local") as never,
|
||||
providerStub("openlibrary") as never,
|
||||
providerStub("googlebooks") as never,
|
||||
providerStub("bnf") as never,
|
||||
providerStub("mangadex") as never,
|
||||
providerStub("comicvine") as never
|
||||
);
|
||||
|
||||
const result = await service.enrichBook(book.id);
|
||||
|
||||
expect(result.coverPath).toMatch(/covers\/[a-f0-9]+\.jpg$/);
|
||||
expect(result.coverPath && existsSync(result.coverPath)).toBe(true);
|
||||
expect(result.metadataStatus).toBe("partial");
|
||||
expect(JSON.parse(result.metadataProvenanceJson ?? "{}")).toMatchObject({ coverPath: "local" });
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("drops sentinel publication dates from provider matches for real affected titles", async () => {
|
||||
const database = createDatabase();
|
||||
const openLibraryProvider: MetadataProvider = {
|
||||
id: "openlibrary",
|
||||
lookup: async () => null,
|
||||
searchByMetadata: async () => [
|
||||
{
|
||||
title: "Harry Potter et les reliques de la mort",
|
||||
author: "J. K. Rowling",
|
||||
publishedDate: "0101-01-01T00:00:00+00:00",
|
||||
description: "Septième année."
|
||||
}
|
||||
]
|
||||
};
|
||||
const service = new MetadataService(
|
||||
database,
|
||||
providerStub("local") as never,
|
||||
openLibraryProvider as never,
|
||||
providerStub("googlebooks") as never,
|
||||
providerStub("bnf") as never,
|
||||
providerStub("mangadex") as never,
|
||||
providerStub("comicvine") as never
|
||||
);
|
||||
|
||||
const result = await service.enrichMetadata(
|
||||
{
|
||||
title: "Harry Potter et les reliques de la mort",
|
||||
author: "J. K. Rowling",
|
||||
description: null,
|
||||
isbn: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
coverPath: null
|
||||
},
|
||||
"/library/Harry Potter et les reliques de la mort.epub",
|
||||
{ remote: true }
|
||||
);
|
||||
|
||||
expect(result.publishedDate).toBeNull();
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("does not overwrite an existing valid date with a provider sentinel", async () => {
|
||||
const database = createDatabase();
|
||||
const now = database.now();
|
||||
const library = database.db
|
||||
.insert(libraries)
|
||||
.values({ name: "Novels", path: "/library", enabled: true, createdAt: now, updatedAt: now })
|
||||
.returning()
|
||||
.get();
|
||||
const book = database.db
|
||||
.insert(books)
|
||||
.values({
|
||||
libraryId: library.id,
|
||||
title: "Lord of the Mysteries",
|
||||
author: "Cuttlefish That Loves Diving",
|
||||
description: null,
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }),
|
||||
localMetadataJson: JSON.stringify({
|
||||
title: "Lord of the Mysteries",
|
||||
author: "Cuttlefish That Loves Diving",
|
||||
year: null,
|
||||
isbn: null,
|
||||
fileTitle: "Lord of the Mysteries",
|
||||
raw: { title: "Lord of the Mysteries", author: "Cuttlefish That Loves Diving", publishedDate: null, fileName: "Lord of the Mysteries" }
|
||||
}),
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: "2018",
|
||||
format: "epub",
|
||||
filePath: "/library/Lord of the Mysteries.epub",
|
||||
coverPath: null,
|
||||
metadataStatus: "partial",
|
||||
metadataProvenanceJson: JSON.stringify({ publishedDate: "existing" }),
|
||||
scanStatus: "succeeded",
|
||||
enrichmentStatus: "succeeded",
|
||||
fileSize: 42,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
const openLibraryProvider: MetadataProvider = {
|
||||
id: "openlibrary",
|
||||
lookup: async () => null,
|
||||
searchByMetadata: async () => [
|
||||
{
|
||||
title: "Lord of the Mysteries",
|
||||
author: "Cuttlefish That Loves Diving",
|
||||
publishedDate: "0101-01-01T00:00:00+00:00",
|
||||
description: "A mysterious sequence begins."
|
||||
}
|
||||
]
|
||||
};
|
||||
const service = new MetadataService(
|
||||
database,
|
||||
providerStub("local") as never,
|
||||
openLibraryProvider as never,
|
||||
providerStub("googlebooks") as never,
|
||||
providerStub("bnf") as never,
|
||||
providerStub("mangadex") as never,
|
||||
providerStub("comicvine") as never
|
||||
);
|
||||
|
||||
const result = await service.enrichBook(book.id);
|
||||
|
||||
expect(result.publishedDate).toBe("2018");
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("records MangaDex provenance and stores its cover locally", async () => {
|
||||
const database = createDatabase();
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/jpeg" }),
|
||||
arrayBuffer: async () => new Uint8Array([9, 8, 7]).buffer
|
||||
} as Response);
|
||||
const mangaDexProvider: MetadataProvider = {
|
||||
id: "mangadex",
|
||||
lookup: async () => null,
|
||||
searchByMetadata: async () => [
|
||||
{
|
||||
title: "Solo Leveling",
|
||||
author: "Chugong",
|
||||
description: "A hunter levels up alone.",
|
||||
publishedDate: "2018",
|
||||
coverUrl: "https://uploads.mangadex.org/covers/manga-1/cover.jpg.512.jpg"
|
||||
}
|
||||
]
|
||||
};
|
||||
const service = new MetadataService(
|
||||
database,
|
||||
providerStub("local") as never,
|
||||
providerStub("openlibrary") as never,
|
||||
providerStub("googlebooks") as never,
|
||||
providerStub("bnf") as never,
|
||||
mangaDexProvider as never,
|
||||
providerStub("comicvine") as never
|
||||
);
|
||||
|
||||
const result = await service.enrichMetadata(
|
||||
{
|
||||
title: "Solo Leveling T03",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
coverPath: null
|
||||
},
|
||||
"/library/Solo Leveling T03.cbz",
|
||||
{ remote: true }
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://uploads.mangadex.org/covers/manga-1/cover.jpg.512.jpg", expect.any(Object));
|
||||
expect(JSON.parse(result.metadataProvenanceJson)).toMatchObject({
|
||||
author: "mangadex",
|
||||
description: "mangadex",
|
||||
publishedDate: "mangadex",
|
||||
coverPath: "mangadex"
|
||||
});
|
||||
expect(result.coverPath).toMatch(/storage\/covers\/.+\.jpg$/);
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("scores MangaDex matches against the cleaned series title instead of the noisy archive title", async () => {
|
||||
const database = createDatabase();
|
||||
const mangaDexProvider: MetadataProvider = {
|
||||
id: "mangadex",
|
||||
lookup: async () => null,
|
||||
searchByMetadata: async () => [
|
||||
{
|
||||
title: "Dragon Ball SD",
|
||||
author: "Naho Ooishi",
|
||||
description: "A super-deformed Dragon Ball spin-off.",
|
||||
publishedDate: "2010",
|
||||
sourceId: "dragon-ball-sd"
|
||||
}
|
||||
]
|
||||
};
|
||||
const service = new MetadataService(
|
||||
database,
|
||||
providerStub("local") as never,
|
||||
providerStub("openlibrary") as never,
|
||||
providerStub("googlebooks") as never,
|
||||
providerStub("bnf") as never,
|
||||
mangaDexProvider as never,
|
||||
providerStub("comicvine") as never
|
||||
);
|
||||
|
||||
const result = await service.enrichMetadata(
|
||||
{
|
||||
title: "Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
coverPath: null
|
||||
},
|
||||
"/library/Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz",
|
||||
{ remote: true }
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
title: "Dragon Ball SD",
|
||||
author: "Naho Ooishi",
|
||||
description: "A super-deformed Dragon Ball spin-off.",
|
||||
publishedDate: "2010"
|
||||
});
|
||||
expect(JSON.parse(result.metadataProvenanceJson)).toMatchObject({
|
||||
title: "local",
|
||||
author: "mangadex",
|
||||
description: "mangadex"
|
||||
});
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("accepts MangaDex alias matches through the provider score title", async () => {
|
||||
const database = createDatabase();
|
||||
const mangaDexProvider: MetadataProvider = {
|
||||
id: "mangadex",
|
||||
lookup: async () => null,
|
||||
searchByMetadata: async () => [
|
||||
{
|
||||
title: "Demon Slayer: Kimetsu Academy",
|
||||
scoreTitle: "Demon Slayer Kimetsu Academy",
|
||||
author: "Natsuki Hokami",
|
||||
description: "School spin-off.",
|
||||
publishedDate: "2021",
|
||||
sourceId: "kimetsu-academy"
|
||||
}
|
||||
]
|
||||
};
|
||||
const service = new MetadataService(
|
||||
database,
|
||||
providerStub("local") as never,
|
||||
providerStub("openlibrary") as never,
|
||||
providerStub("googlebooks") as never,
|
||||
providerStub("bnf") as never,
|
||||
mangaDexProvider as never,
|
||||
providerStub("comicvine") as never
|
||||
);
|
||||
|
||||
const result = await service.enrichMetadata(
|
||||
{
|
||||
title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
coverPath: null
|
||||
},
|
||||
"/library/Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz.cbz",
|
||||
{ remote: true }
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
title: "Demon Slayer School Days",
|
||||
author: "Natsuki Hokami",
|
||||
description: "School spin-off.",
|
||||
publishedDate: "2021"
|
||||
});
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("re-enriches existing manga with the cleaned series title on the live book path", async () => {
|
||||
const database = createDatabase();
|
||||
mkdirSync(database.config.storageDir, { recursive: true });
|
||||
const filePath = join(database.config.storageDir, "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz.cbz");
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
||||
zip.writeZip(filePath);
|
||||
const now = database.now();
|
||||
const library = database.db
|
||||
.insert(libraries)
|
||||
.values({ name: "Manga", path: database.config.storageDir, enabled: true, createdAt: now, updatedAt: now })
|
||||
.returning()
|
||||
.get();
|
||||
const book = database.db
|
||||
.insert(books)
|
||||
.values({
|
||||
libraryId: library.id,
|
||||
title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }),
|
||||
localMetadataJson: JSON.stringify({
|
||||
title: "Demon Slayer School Days T01 FRENCH",
|
||||
author: null,
|
||||
year: null,
|
||||
isbn: null,
|
||||
fileTitle: "Demon Slayer School Days T01 FRENCH",
|
||||
raw: {
|
||||
title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz",
|
||||
author: null,
|
||||
publishedDate: null,
|
||||
fileName: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz"
|
||||
}
|
||||
}),
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
format: "cbz",
|
||||
filePath,
|
||||
coverPath: null,
|
||||
metadataStatus: "none",
|
||||
metadataProvenanceJson: JSON.stringify({ title: "local" }),
|
||||
scanStatus: "succeeded",
|
||||
enrichmentStatus: "succeeded",
|
||||
fileSize: 42,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
const mangaDexSearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise<MetadataMatch[]>>(async () => [
|
||||
{
|
||||
title: "Demon Slayer School Days",
|
||||
author: "Natsuki Hokami",
|
||||
description: "School spin-off.",
|
||||
publishedDate: "2021",
|
||||
sourceId: "demon-slayer-school-days"
|
||||
}
|
||||
]);
|
||||
const service = new MetadataService(
|
||||
database,
|
||||
providerStub("local") as never,
|
||||
providerStub("openlibrary") as never,
|
||||
providerStub("googlebooks") as never,
|
||||
providerStub("bnf") as never,
|
||||
{ id: "mangadex", lookup: async () => null, searchByMetadata: mangaDexSearch } as never,
|
||||
providerStub("comicvine") as never
|
||||
);
|
||||
|
||||
const result = await service.enrichBook(book.id);
|
||||
|
||||
expect(mangaDexSearch.mock.calls[0]?.[0].title).toBe("Demon Slayer School Days");
|
||||
expect(result).toMatchObject({
|
||||
title: "Demon Slayer School Days",
|
||||
author: "Natsuki Hokami",
|
||||
description: "School spin-off.",
|
||||
publishedDate: "2021"
|
||||
});
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
});
|
||||
|
||||
function createDatabase(): DatabaseService {
|
||||
|
||||
@ -1,21 +1,45 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, extname, join } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
MetadataSourcesConfigDto,
|
||||
UpdateMetadataSourcesConfigDto
|
||||
} from "@readabook/shared";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { automationSettings, books, metadataSourceConfig } from "../database/schema.js";
|
||||
import { BookMetadata } from "../scanner/metadata.js";
|
||||
import { automationSettings, books, metadataSourceConfig, series } from "../database/schema.js";
|
||||
import { BookMetadata, extractMetadata } from "../scanner/metadata.js";
|
||||
import { BnfProvider } from "./adapters/bnf.provider.js";
|
||||
import { ComicVineProvider } from "./adapters/comic-vine.provider.js";
|
||||
import { GoogleBooksProvider } from "./adapters/google-books.provider.js";
|
||||
import { LocalMetadataProvider } from "./adapters/local.provider.js";
|
||||
import { MangaDexProvider } from "./adapters/mangadex.provider.js";
|
||||
import { OpenLibraryProvider } from "./adapters/open-library.provider.js";
|
||||
import { BookIdentifiers, LocalMetadataHints, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "./metadata.types.js";
|
||||
import { describeMetadataProviderError, providerFetch } from "./adapters/provider-fetch.js";
|
||||
import {
|
||||
BookIdentifiers,
|
||||
LocalMetadataHints,
|
||||
MetadataField,
|
||||
MetadataMatch,
|
||||
MetadataProvider,
|
||||
MetadataProviderConfig,
|
||||
MetadataProviderId,
|
||||
MetadataProvenance,
|
||||
MetadataSearchQuery,
|
||||
MetadataStatus
|
||||
} from "./metadata.types.js";
|
||||
import { ExtractIdentifiers, toIsbn13 } from "./use-cases/extract-identifiers.js";
|
||||
import { ExtractLocalMetadataHints } from "./use-cases/extract-local-metadata-hints.js";
|
||||
import { extractSeriesVolume } from "./use-cases/extract-series-volume.js";
|
||||
import { normalizePublishedDate } from "./use-cases/normalize-published-date.js";
|
||||
import { ResolveProviderChain } from "./use-cases/resolve-provider-chain.js";
|
||||
import { ScoreMetadataMatch } from "./use-cases/score-metadata-match.js";
|
||||
import { ScoredMetadataMatch, ScoreMetadataMatch } from "./use-cases/score-metadata-match.js";
|
||||
|
||||
type ProviderCandidate = ScoredMetadataMatch & {
|
||||
provider: MetadataProviderId;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MetadataService {
|
||||
@ -29,9 +53,11 @@ export class MetadataService {
|
||||
local: LocalMetadataProvider,
|
||||
openLibrary: OpenLibraryProvider,
|
||||
googleBooks: GoogleBooksProvider,
|
||||
bnf: BnfProvider
|
||||
bnf: BnfProvider,
|
||||
mangaDex: MangaDexProvider,
|
||||
comicVine: ComicVineProvider
|
||||
) {
|
||||
this.resolveProviderChain = new ResolveProviderChain([local, openLibrary, googleBooks, bnf]);
|
||||
this.resolveProviderChain = new ResolveProviderChain([local, openLibrary, googleBooks, bnf, mangaDex, comicVine]);
|
||||
}
|
||||
|
||||
getSourcesConfig(): MetadataSourcesConfigDto {
|
||||
@ -75,96 +101,119 @@ export class MetadataService {
|
||||
localMetadata: BookMetadata,
|
||||
filePath: string,
|
||||
options: { remote: boolean }
|
||||
): Promise<BookMetadata & { isbn13: string | null; identifiersJson: string; localMetadataJson: string }> {
|
||||
): Promise<
|
||||
BookMetadata & {
|
||||
isbn13: string | null;
|
||||
identifiersJson: string;
|
||||
localMetadataJson: string;
|
||||
metadataStatus: MetadataStatus;
|
||||
metadataProvenanceJson: string;
|
||||
}
|
||||
> {
|
||||
const identifiers = this.extractIdentifiers.fromMetadataAndFile(localMetadata, filePath);
|
||||
const local = this.extractLocalMetadataHints.fromMetadataAndFile(localMetadata, filePath);
|
||||
const configs = this.getProviderConfigs();
|
||||
const chain = options.remote
|
||||
? this.resolveProviderChain.resolve(configs)
|
||||
: this.resolveProviderChain.resolve(configs).filter((entry) => entry.provider.id === "local");
|
||||
let merged: BookMetadata = { ...localMetadata };
|
||||
const candidates: ProviderCandidate[] = [];
|
||||
const query = this.buildSearchQuery(local, identifiers, filePath);
|
||||
|
||||
for (const { provider, config } of chain) {
|
||||
if (provider.id === "local") continue;
|
||||
try {
|
||||
const hasIsbn = Boolean(identifiers.isbn13 ?? identifiers.isbn10);
|
||||
const match =
|
||||
provider.id === "local" || hasIsbn
|
||||
? await provider.lookup(
|
||||
{
|
||||
title: merged.title,
|
||||
author: merged.author,
|
||||
filePath,
|
||||
sourceId: null,
|
||||
identifiers,
|
||||
local
|
||||
},
|
||||
config
|
||||
)
|
||||
: null;
|
||||
const match = hasIsbn
|
||||
? await provider.lookup(
|
||||
{
|
||||
title: local.title,
|
||||
author: local.author,
|
||||
filePath,
|
||||
sourceId: null,
|
||||
identifiers,
|
||||
local
|
||||
},
|
||||
config
|
||||
)
|
||||
: null;
|
||||
if (match) {
|
||||
merged = mergeMetadata(merged, match);
|
||||
const completedMatch = match.description
|
||||
? match
|
||||
: mergeMetadataMatch(match, await this.searchMissingDescription(provider, config, local, identifiers, filePath));
|
||||
candidates.push(scoreProviderCandidate(this.scoreMetadataMatch, query, completedMatch, provider.id, config.priority));
|
||||
continue;
|
||||
}
|
||||
if (!options.remote || provider.id === "local") continue;
|
||||
const query = {
|
||||
title: local.title,
|
||||
author: local.author,
|
||||
year: local.year,
|
||||
isbn: identifiers.isbn13 ?? identifiers.isbn10 ?? local.isbn
|
||||
};
|
||||
const best = this.scoreMetadataMatch.best(query, await provider.searchByMetadata(query, config));
|
||||
if (!options.remote) continue;
|
||||
const matches = await provider.searchByMetadata(query, config);
|
||||
const best = this.scoreMetadataMatch.best(query, matches);
|
||||
if (!best && matches.length) {
|
||||
console.info(
|
||||
`[metadata] Provider ${provider.id} returned ${matches.length} result(s) rejected by scoring for "${query.title}"`
|
||||
);
|
||||
}
|
||||
if (best) {
|
||||
if (!isActionableSearchMatch(best.match)) continue;
|
||||
merged = shouldReplaceAmbiguousIdentification(localMetadata, merged, best.match)
|
||||
? mergeMetadata({ ...localMetadata, author: null, isbn: null, description: null, language: null, publisher: null, publishedDate: null }, best.match)
|
||||
: mergeMetadata(merged, best.match);
|
||||
let providerMatch = best.match;
|
||||
|
||||
const detailedMatch = await this.lookupSearchMatchDetails(provider, config, filePath, identifiers, local, merged, best.match);
|
||||
if (detailedMatch) merged = mergeMetadata(merged, detailedMatch);
|
||||
const detailedMatch = await this.lookupSearchMatchDetails(provider, config, filePath, identifiers, local, best.match);
|
||||
if (detailedMatch) providerMatch = mergeMetadataMatch(detailedMatch, best.match);
|
||||
candidates.push(scoreProviderCandidate(this.scoreMetadataMatch, query, providerMatch, provider.id, config.priority));
|
||||
}
|
||||
} catch {
|
||||
// Provider failures must not block local ingestion.
|
||||
} catch (error) {
|
||||
console.warn(`[metadata] Provider ${provider.id} failed for "${query.title}": ${describeMetadataProviderError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const materializedCandidates = await this.materializeQualifiedCovers(candidates, filePath);
|
||||
const localProvenance = provenanceFromLocal(localMetadata);
|
||||
const { metadata: merged, provenance: remoteProvenance } = mergeCandidatesWithLocal(
|
||||
materializedCandidates,
|
||||
localMetadata,
|
||||
identifiers,
|
||||
query.title
|
||||
);
|
||||
let provenance: MetadataProvenance = { ...localProvenance, ...remoteProvenance };
|
||||
if (merged.isbn && !provenance.isbn) provenance.isbn = "local";
|
||||
const isbn13 = identifiers.isbn13 ?? (merged.isbn ? toIsbn13(merged.isbn) : null);
|
||||
const metadataStatus = computeMetadataStatus(merged);
|
||||
return {
|
||||
...merged,
|
||||
isbn: merged.isbn ?? isbn13 ?? identifiers.isbn10,
|
||||
isbn13,
|
||||
identifiersJson: JSON.stringify(identifiers),
|
||||
localMetadataJson: JSON.stringify(local)
|
||||
localMetadataJson: JSON.stringify(local),
|
||||
metadataStatus,
|
||||
metadataProvenanceJson: JSON.stringify(provenance)
|
||||
};
|
||||
}
|
||||
|
||||
async enrichBook(bookId: number): Promise<typeof books.$inferSelect> {
|
||||
const book = this.database.db.select().from(books).where(eq(books.id, bookId)).get();
|
||||
if (!book) throw new NotFoundException("Book not found");
|
||||
const metadata: BookMetadata = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
description: book.description,
|
||||
isbn: book.isbn,
|
||||
language: book.language,
|
||||
publisher: book.publisher,
|
||||
publishedDate: book.publishedDate,
|
||||
coverPath: book.coverPath
|
||||
};
|
||||
const local = parseStoredLocalMetadata(book.localMetadataJson);
|
||||
const metadata = await this.extractCurrentLocalMetadata(book, local);
|
||||
const enriched = await this.enrichMetadata(metadata, book.filePath, { remote: true });
|
||||
const next = preserveExistingWhenMissing(enriched, book, book.filePath);
|
||||
const seriesInfo = this.resolveSeries(next.title, book.filePath);
|
||||
return this.database.db
|
||||
.update(books)
|
||||
.set({
|
||||
title: enriched.title,
|
||||
author: enriched.author,
|
||||
description: enriched.description,
|
||||
isbn: enriched.isbn,
|
||||
isbn13: enriched.isbn13,
|
||||
seriesId: seriesInfo.seriesId,
|
||||
title: next.title,
|
||||
author: next.author,
|
||||
description: next.description,
|
||||
isbn: next.isbn,
|
||||
isbn13: next.isbn13,
|
||||
identifiersJson: enriched.identifiersJson,
|
||||
localMetadataJson: enriched.localMetadataJson,
|
||||
language: enriched.language,
|
||||
publisher: enriched.publisher,
|
||||
publishedDate: enriched.publishedDate,
|
||||
coverPath: enriched.coverPath,
|
||||
language: next.language,
|
||||
publisher: next.publisher,
|
||||
publishedDate: next.publishedDate,
|
||||
volumeNumber: seriesInfo.volumeNumber,
|
||||
volumeLabel: seriesInfo.volumeLabel,
|
||||
coverPath: next.coverPath,
|
||||
metadataStatus: next.metadataStatus,
|
||||
metadataProvenanceJson: next.metadataProvenanceJson,
|
||||
updatedAt: this.database.now()
|
||||
})
|
||||
.where(eq(books.id, book.id))
|
||||
@ -172,6 +221,57 @@ export class MetadataService {
|
||||
.get();
|
||||
}
|
||||
|
||||
private async extractCurrentLocalMetadata(book: typeof books.$inferSelect, local: LocalMetadataHints | null): Promise<BookMetadata> {
|
||||
const fallback: BookMetadata = {
|
||||
title: local?.title ?? book.title,
|
||||
author: local ? local.author : book.author,
|
||||
description: null,
|
||||
isbn: local ? local.isbn : book.isbn,
|
||||
language: null,
|
||||
publisher: book.publisher,
|
||||
publishedDate: normalizePublishedDate(local ? local.year : book.publishedDate),
|
||||
coverPath: book.coverPath
|
||||
};
|
||||
if (!existsSync(book.filePath)) return fallback;
|
||||
try {
|
||||
const extracted = await extractMetadata(book.filePath, this.database.config.storageDir);
|
||||
return {
|
||||
title: extracted.title || fallback.title,
|
||||
author: extracted.author ?? fallback.author,
|
||||
description: extracted.description ?? fallback.description,
|
||||
isbn: extracted.isbn ?? fallback.isbn,
|
||||
language: extracted.language ?? fallback.language,
|
||||
publisher: extracted.publisher ?? fallback.publisher,
|
||||
publishedDate: normalizePublishedDate(extracted.publishedDate) ?? fallback.publishedDate,
|
||||
coverPath: extracted.coverPath ?? fallback.coverPath
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSeries(title: string, filePath: string): { seriesId: number; volumeNumber: number | null; volumeLabel: string | null } {
|
||||
const parsed = extractSeriesVolume(title, filePath);
|
||||
const now = this.database.now();
|
||||
const row = this.database.db
|
||||
.insert(series)
|
||||
.values({
|
||||
title: parsed.seriesTitle,
|
||||
normalizedTitle: parsed.normalizedSeriesTitle,
|
||||
description: null,
|
||||
publisher: null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: series.normalizedTitle,
|
||||
set: { title: parsed.seriesTitle, updatedAt: now }
|
||||
})
|
||||
.returning({ id: series.id })
|
||||
.get();
|
||||
return { seriesId: row.id, volumeNumber: parsed.volumeNumber, volumeLabel: parsed.volumeLabel };
|
||||
}
|
||||
|
||||
private getProviderConfigs(): MetadataProviderConfig[] {
|
||||
return this.database.db
|
||||
.select()
|
||||
@ -195,7 +295,6 @@ export class MetadataService {
|
||||
filePath: string,
|
||||
identifiers: BookIdentifiers,
|
||||
local: LocalMetadataHints,
|
||||
merged: BookMetadata,
|
||||
match: MetadataMatch
|
||||
): Promise<MetadataMatch | null> {
|
||||
const derivedIdentifiers = {
|
||||
@ -206,13 +305,12 @@ export class MetadataService {
|
||||
};
|
||||
const hasNewIdentifier = derivedIdentifiers.isbn13 !== identifiers.isbn13 || derivedIdentifiers.isbn10 !== identifiers.isbn10;
|
||||
const hasLookupTarget = hasNewIdentifier || Boolean(match.sourceId);
|
||||
const needsDetails = !merged.description && hasLookupTarget;
|
||||
if (!hasLookupTarget && !needsDetails) return null;
|
||||
if (!hasLookupTarget) return null;
|
||||
|
||||
return provider.lookup(
|
||||
{
|
||||
title: merged.title,
|
||||
author: merged.author,
|
||||
title: match.title ?? local.title,
|
||||
author: match.author ?? local.author,
|
||||
filePath,
|
||||
sourceId: match.sourceId,
|
||||
identifiers: derivedIdentifiers,
|
||||
@ -221,26 +319,309 @@ export class MetadataService {
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
private async searchMissingDescription(
|
||||
provider: MetadataProvider,
|
||||
config: MetadataProviderConfig,
|
||||
local: LocalMetadataHints,
|
||||
identifiers: BookIdentifiers,
|
||||
filePath: string
|
||||
): Promise<MetadataMatch | null> {
|
||||
const query = this.buildSearchQuery(local, identifiers, filePath);
|
||||
const matches = await provider.searchByMetadata(query, config);
|
||||
return (
|
||||
matches
|
||||
.map((match) => ({ match, score: this.scoreMetadataMatch.score(query, match) }))
|
||||
.filter((entry) => entry.match.description && entry.score >= 75)
|
||||
.sort((left, right) => right.score - left.score)[0]?.match ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private buildSearchQuery(local: LocalMetadataHints, identifiers: BookIdentifiers, filePath: string): MetadataSearchQuery {
|
||||
return {
|
||||
title: extractSeriesVolume(local.title, filePath).seriesTitle,
|
||||
author: local.author,
|
||||
year: local.year,
|
||||
isbn: identifiers.isbn13 ?? identifiers.isbn10 ?? local.isbn
|
||||
};
|
||||
}
|
||||
|
||||
private async materializeCover(match: MetadataMatch, filePath: string, provider: MetadataProviderId): Promise<MetadataMatch> {
|
||||
if (match.coverPath || !match.coverUrl) return match;
|
||||
try {
|
||||
const response = await providerFetch("cover", match.coverUrl, { timeoutMs: 5000 });
|
||||
if (!response.ok) return match;
|
||||
const data = Buffer.from(await response.arrayBuffer());
|
||||
if (!data.length) return match;
|
||||
const extension = coverExtension(match.coverUrl, response.headers.get("content-type"));
|
||||
const hash = createHash("sha256").update(`${filePath}:${provider}:${match.coverUrl}`).digest("hex").slice(0, 24);
|
||||
const target = join(this.database.config.storageDir, "covers", `${hash}${extension}`);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, data);
|
||||
return { ...match, coverPath: target };
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
private async materializeQualifiedCovers(candidates: ProviderCandidate[], filePath: string): Promise<ProviderCandidate[]> {
|
||||
const materialized: ProviderCandidate[] = [];
|
||||
for (const candidate of candidates) {
|
||||
if (isQualifiedSourceCover(candidate)) {
|
||||
materialized.push({
|
||||
...candidate,
|
||||
match: await this.materializeCover(candidate.match, filePath, candidate.provider)
|
||||
});
|
||||
} else {
|
||||
materialized.push(candidate);
|
||||
}
|
||||
}
|
||||
return materialized;
|
||||
}
|
||||
}
|
||||
|
||||
function isActionableSearchMatch(match: MetadataMatch): boolean {
|
||||
return Boolean(match.isbn ?? match.sourceId ?? match.description);
|
||||
}
|
||||
|
||||
function shouldReplaceAmbiguousIdentification(local: BookMetadata, current: BookMetadata, next: MetadataMatch): boolean {
|
||||
if (local.author || local.isbn || !current.isbn || !next.isbn || current.isbn === next.isbn) return false;
|
||||
return Boolean(next.description);
|
||||
}
|
||||
|
||||
function mergeMetadata(current: BookMetadata, next: MetadataMatch): BookMetadata {
|
||||
function mergeMetadataMatch(current: MetadataMatch, next: MetadataMatch | null): MetadataMatch {
|
||||
if (!next) return current;
|
||||
return {
|
||||
title: next.title ?? current.title,
|
||||
author: current.author ?? next.author ?? null,
|
||||
description: current.description ?? next.description ?? null,
|
||||
isbn: current.isbn ?? next.isbn ?? null,
|
||||
language: current.language ?? next.language ?? null,
|
||||
publisher: current.publisher ?? next.publisher ?? null,
|
||||
publishedDate: current.publishedDate ?? next.publishedDate ?? null,
|
||||
coverPath: current.coverPath ?? next.coverPath ?? null
|
||||
title: current.title ?? next.title,
|
||||
author: current.author ?? next.author,
|
||||
description: current.description ?? next.description,
|
||||
isbn: current.isbn ?? next.isbn,
|
||||
language: current.language ?? next.language,
|
||||
publisher: current.publisher ?? next.publisher,
|
||||
publishedDate: normalizePublishedDate(current.publishedDate) ?? normalizePublishedDate(next.publishedDate),
|
||||
coverPath: current.coverPath ?? next.coverPath,
|
||||
coverUrl: current.coverUrl ?? next.coverUrl,
|
||||
sourceId: current.sourceId ?? next.sourceId,
|
||||
identifiers: current.identifiers ?? next.identifiers
|
||||
};
|
||||
}
|
||||
|
||||
const metadataFields: MetadataField[] = ["title", "author", "description", "isbn", "language", "publisher", "publishedDate", "coverPath"];
|
||||
const fillableMetadataFields: MetadataField[] = ["author", "description", "isbn", "language", "publisher", "publishedDate"];
|
||||
|
||||
function scoreProviderCandidate(
|
||||
scorer: ScoreMetadataMatch,
|
||||
query: MetadataSearchQuery,
|
||||
match: MetadataMatch,
|
||||
provider: MetadataProviderId,
|
||||
priority: number
|
||||
): ProviderCandidate {
|
||||
return { ...scorer.details(query, match), provider, priority };
|
||||
}
|
||||
|
||||
function mergeCandidatesWithLocal(
|
||||
candidates: ProviderCandidate[],
|
||||
local: BookMetadata,
|
||||
identifiers: BookIdentifiers,
|
||||
title: string
|
||||
): { metadata: BookMetadata; provenance: MetadataProvenance } {
|
||||
const sorted = [...candidates].sort(compareProviderCandidates);
|
||||
const retained = sorted[0] ?? null;
|
||||
const completionOrder = [...(retained ? [retained] : []), ...sorted.filter((candidate) => candidate !== retained)];
|
||||
const metadata: BookMetadata = {
|
||||
title,
|
||||
author: local.author ?? null,
|
||||
description: local.description ?? null,
|
||||
isbn: identifiers.isbn13 ?? identifiers.isbn10 ?? local.isbn ?? null,
|
||||
language: local.language ?? null,
|
||||
publisher: local.publisher ?? null,
|
||||
publishedDate: normalizePublishedDate(local.publishedDate),
|
||||
coverPath: local.coverPath ?? null
|
||||
};
|
||||
const provenance: MetadataProvenance = {};
|
||||
for (const field of fillableMetadataFields) {
|
||||
if (hasMetadataValue(metadata[field])) continue;
|
||||
const source = completionOrder.find((candidate) => hasMetadataValue(normalizeCandidateField(candidate.match, field)));
|
||||
if (!source) continue;
|
||||
metadata[field] = normalizeCandidateField(source.match, field) as never;
|
||||
provenance[field] = source.provider;
|
||||
provenance[`${field}Score` as MetadataField] = String(source.score) as never;
|
||||
}
|
||||
const coverSource = completionOrder.find((candidate) => isQualifiedSourceCover(candidate) && hasMetadataValue(candidate.match.coverPath));
|
||||
if (coverSource && shouldUseSourceCover(metadata.coverPath)) {
|
||||
metadata.coverPath = coverSource.match.coverPath ?? null;
|
||||
provenance.coverPath = coverSource.provider;
|
||||
provenance.coverPathScore = String(coverSource.score) as never;
|
||||
}
|
||||
return { metadata, provenance };
|
||||
}
|
||||
|
||||
function compareProviderCandidates(left: ProviderCandidate, right: ProviderCandidate): number {
|
||||
if (left.isbnMatch !== right.isbnMatch) return left.isbnMatch ? -1 : 1;
|
||||
const leftHighConfidence = isHighConfidenceSelection(left);
|
||||
const rightHighConfidence = isHighConfidenceSelection(right);
|
||||
if (leftHighConfidence && rightHighConfidence) return left.priority - right.priority;
|
||||
if (leftHighConfidence !== rightHighConfidence) return leftHighConfidence ? -1 : 1;
|
||||
if (left.score !== right.score) return right.score - left.score;
|
||||
return left.priority - right.priority;
|
||||
}
|
||||
|
||||
function isHighConfidenceSelection(candidate: ProviderCandidate): boolean {
|
||||
return candidate.titleScore > 90 && (candidate.authorScore == null || candidate.authorScore >= 15);
|
||||
}
|
||||
|
||||
function isQualifiedSourceCover(candidate: ProviderCandidate): boolean {
|
||||
return candidate.score >= 80 && candidate.titleScore >= 85 && Boolean(candidate.match.coverPath ?? candidate.match.coverUrl);
|
||||
}
|
||||
|
||||
function shouldUseSourceCover(currentCoverPath: string | null): boolean {
|
||||
return !hasMetadataValue(currentCoverPath) || isLocalCoverPath(currentCoverPath);
|
||||
}
|
||||
|
||||
function isLocalCoverPath(value: string): boolean {
|
||||
return /[/\\]covers[/\\][a-f0-9]{24}\.[a-z0-9]+$/i.test(value);
|
||||
}
|
||||
|
||||
function normalizeCandidateField(match: MetadataMatch, field: MetadataField): string | null {
|
||||
if (field === "publishedDate") return normalizePublishedDate(match.publishedDate);
|
||||
return match[field] ?? null;
|
||||
}
|
||||
|
||||
function hasMetadataValue(value: string | null | undefined): value is string {
|
||||
return Boolean(value && value.trim());
|
||||
}
|
||||
|
||||
function provenanceFromLocal(local: BookMetadata): MetadataProvenance {
|
||||
const provenance: MetadataProvenance = {};
|
||||
for (const field of metadataFields) {
|
||||
if (local[field] != null && local[field] !== "") provenance[field] = "local";
|
||||
}
|
||||
return provenance;
|
||||
}
|
||||
|
||||
function computeMetadataStatus(metadata: Pick<BookMetadata, "author" | "description" | "isbn" | "language" | "publisher" | "publishedDate" | "coverPath">): MetadataStatus {
|
||||
const hasCover = Boolean(metadata.coverPath);
|
||||
const filled = [metadata.author, metadata.description, metadata.isbn, metadata.language, metadata.publisher, metadata.publishedDate].filter(Boolean).length;
|
||||
if (hasCover && filled >= 2) return "enriched";
|
||||
if (hasCover || filled > 0) return "partial";
|
||||
return "none";
|
||||
}
|
||||
|
||||
function mergeExistingProvenance(
|
||||
enriched: BookMetadata & { metadataProvenanceJson: string },
|
||||
existing: typeof books.$inferSelect,
|
||||
finalValues: BookMetadata & { isbn13: string | null }
|
||||
): MetadataProvenance {
|
||||
const next = parseProvenance(enriched.metadataProvenanceJson);
|
||||
const previous = parseProvenance(existing.metadataProvenanceJson);
|
||||
const provenance: MetadataProvenance = { ...previous };
|
||||
for (const field of metadataFields) {
|
||||
if (field === "title") {
|
||||
if (finalValues.title && !provenance.title) {
|
||||
provenance.title = finalValues.title === enriched.title && finalValues.title !== existing.title ? (next.title ?? "local") : (previous.title ?? "existing");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (field === "publishedDate") {
|
||||
const finalDate = normalizePublishedDate(finalValues.publishedDate);
|
||||
if (finalDate && finalDate === normalizePublishedDate(enriched.publishedDate) && finalDate !== normalizePublishedDate(existing.publishedDate)) {
|
||||
provenance[field] = next[field] ?? provenance[field];
|
||||
copyScoreProvenance(next, provenance, field);
|
||||
} else if (finalDate && !provenance[field]) {
|
||||
provenance[field] = previous[field] ?? "existing";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (field === "coverPath" && finalValues.coverPath && finalValues.coverPath === enriched.coverPath && finalValues.coverPath !== existing.coverPath) {
|
||||
provenance.coverPath = next.coverPath ?? provenance.coverPath;
|
||||
copyScoreProvenance(next, provenance, field);
|
||||
continue;
|
||||
}
|
||||
if (finalValues[field] && finalValues[field] === enriched[field] && finalValues[field] !== existing[field]) {
|
||||
provenance[field] = next[field] ?? provenance[field];
|
||||
copyScoreProvenance(next, provenance, field);
|
||||
continue;
|
||||
}
|
||||
if (finalValues[field] != null && existing[field] != null && !provenance[field]) {
|
||||
provenance[field] = previous[field] ?? "existing";
|
||||
}
|
||||
}
|
||||
return provenance;
|
||||
}
|
||||
|
||||
function copyScoreProvenance(source: MetadataProvenance, target: MetadataProvenance, field: MetadataField): void {
|
||||
const scoreKey = `${field}Score`;
|
||||
if (source[scoreKey]) target[scoreKey] = source[scoreKey];
|
||||
}
|
||||
|
||||
function parseProvenance(value: string | null): MetadataProvenance {
|
||||
if (!value) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value) as MetadataProvenance;
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function coverExtension(url: string, contentType: string | null): string {
|
||||
if (contentType?.includes("png")) return ".png";
|
||||
if (contentType?.includes("webp")) return ".webp";
|
||||
if (contentType?.includes("gif")) return ".gif";
|
||||
const fromUrl = extname(new URL(url).pathname).toLowerCase();
|
||||
return fromUrl === ".png" || fromUrl === ".webp" || fromUrl === ".gif" || fromUrl === ".jpg" || fromUrl === ".jpeg" ? fromUrl : ".jpg";
|
||||
}
|
||||
|
||||
function parseStoredLocalMetadata(value: string | null): LocalMetadataHints | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<LocalMetadataHints>;
|
||||
return typeof parsed.title === "string" ? (parsed as LocalMetadataHints) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function preserveExistingWhenMissing(
|
||||
enriched: BookMetadata & {
|
||||
isbn13: string | null;
|
||||
identifiersJson: string;
|
||||
localMetadataJson: string;
|
||||
metadataStatus: MetadataStatus;
|
||||
metadataProvenanceJson: string;
|
||||
},
|
||||
existing: typeof books.$inferSelect,
|
||||
filePath: string
|
||||
): BookMetadata & { isbn13: string | null; metadataStatus: MetadataStatus; metadataProvenanceJson: string } {
|
||||
const previousProvenance = parseProvenance(existing.metadataProvenanceJson);
|
||||
const next = {
|
||||
title: chooseTitle(existing.title, enriched.title, filePath),
|
||||
author: existing.author ?? enriched.author,
|
||||
description: existing.description ?? enriched.description,
|
||||
isbn: existing.isbn ?? enriched.isbn,
|
||||
isbn13: existing.isbn13 ?? enriched.isbn13,
|
||||
language: existing.language ?? enriched.language,
|
||||
publisher: existing.publisher ?? enriched.publisher,
|
||||
publishedDate: normalizePublishedDate(existing.publishedDate) ?? normalizePublishedDate(enriched.publishedDate),
|
||||
coverPath: chooseCoverPath(existing.coverPath, enriched.coverPath, previousProvenance)
|
||||
};
|
||||
const provenance = mergeExistingProvenance(enriched, existing, next);
|
||||
return {
|
||||
...next,
|
||||
metadataStatus: computeMetadataStatus(next),
|
||||
metadataProvenanceJson: JSON.stringify(provenance)
|
||||
};
|
||||
}
|
||||
|
||||
function chooseCoverPath(existingCoverPath: string | null, enrichedCoverPath: string | null, previousProvenance: MetadataProvenance): string | null {
|
||||
if (!existingCoverPath) return enrichedCoverPath;
|
||||
if (!enrichedCoverPath || enrichedCoverPath === existingCoverPath) return existingCoverPath;
|
||||
return canReplaceExistingCover(existingCoverPath, previousProvenance) ? enrichedCoverPath : existingCoverPath;
|
||||
}
|
||||
|
||||
function chooseTitle(existingTitle: string, enrichedTitle: string, filePath: string): string {
|
||||
if (!enrichedTitle) return existingTitle;
|
||||
if (!existingTitle) return enrichedTitle;
|
||||
const parsedExisting = extractSeriesVolume(existingTitle, filePath).seriesTitle;
|
||||
return parsedExisting === enrichedTitle && existingTitle !== enrichedTitle ? enrichedTitle : existingTitle;
|
||||
}
|
||||
|
||||
function canReplaceExistingCover(existingCoverPath: string, previousProvenance: MetadataProvenance): boolean {
|
||||
const provenance = previousProvenance.coverPath;
|
||||
return (provenance == null || provenance === "local" || provenance === "existing") && isLocalCoverPath(existingCoverPath);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { BookMetadata } from "../scanner/metadata.js";
|
||||
|
||||
export type MetadataProviderId = "local" | "openlibrary" | "googlebooks" | "bnf";
|
||||
export type MetadataProviderId = "local" | "openlibrary" | "googlebooks" | "bnf" | "mangadex" | "comicvine";
|
||||
|
||||
export type BookIdentifiers = {
|
||||
isbn10: string | null;
|
||||
@ -40,9 +40,17 @@ export type MetadataSearchQuery = {
|
||||
|
||||
export type MetadataMatch = Partial<BookMetadata> & {
|
||||
sourceId?: string | null;
|
||||
coverUrl?: string | null;
|
||||
identifiers?: Partial<BookIdentifiers>;
|
||||
scoreTitle?: string | null;
|
||||
};
|
||||
|
||||
export type MetadataField = "title" | "author" | "description" | "isbn" | "language" | "publisher" | "publishedDate" | "coverPath";
|
||||
|
||||
export type MetadataStatus = "enriched" | "partial" | "none";
|
||||
|
||||
export type MetadataProvenance = Partial<Record<string, MetadataProviderId | "existing" | string>>;
|
||||
|
||||
export type MetadataProviderConfig = {
|
||||
provider: MetadataProviderId;
|
||||
enabled: boolean;
|
||||
|
||||
23
apps/api/src/metadata/normalize-published-date.test.ts
Normal file
23
apps/api/src/metadata/normalize-published-date.test.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizePublishedDate } from "./use-cases/normalize-published-date.js";
|
||||
|
||||
describe("normalizePublishedDate", () => {
|
||||
it("rejects sentinel and absurd dates seen in real metadata providers", () => {
|
||||
expect(normalizePublishedDate("0101-01-01T00:00:00+00:00")).toBeNull();
|
||||
expect(normalizePublishedDate("0001-01-01")).toBeNull();
|
||||
expect(normalizePublishedDate("1970-01-01")).toBeNull();
|
||||
expect(normalizePublishedDate("0000")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps only credible supported date formats", () => {
|
||||
expect(normalizePublishedDate("2007")).toBe("2007");
|
||||
expect(normalizePublishedDate("2007-07")).toBe("2007-07");
|
||||
expect(normalizePublishedDate("2007-07-21")).toBe("2007-07-21");
|
||||
expect(normalizePublishedDate("2007-07-21T00:00:00+00:00")).toBe("2007-07-21");
|
||||
});
|
||||
|
||||
it("rejects years outside the supported publication range", () => {
|
||||
expect(normalizePublishedDate("1499")).toBeNull();
|
||||
expect(normalizePublishedDate("2028")).toBeNull();
|
||||
});
|
||||
});
|
||||
67
apps/api/src/metadata/use-cases/extract-series-volume.ts
Normal file
67
apps/api/src/metadata/use-cases/extract-series-volume.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { basename, extname } from "node:path";
|
||||
|
||||
export type SeriesVolume = {
|
||||
seriesTitle: string;
|
||||
normalizedSeriesTitle: string;
|
||||
volumeNumber: number | null;
|
||||
volumeLabel: string | null;
|
||||
};
|
||||
|
||||
export function extractSeriesVolume(title: string, filePath?: string | null): SeriesVolume {
|
||||
const source = cleanSeriesSource(filePath ? basename(filePath, extname(filePath)) : title) || cleanSeriesSource(title) || title;
|
||||
const explicit = source.match(/\b(?:T(?:ome)?|Vol(?:ume)?\.?|Issue|No\.?)\s*0*(\d{1,4})\b/i) ?? source.match(/#\s*0*(\d{1,4})\b/);
|
||||
if (explicit?.[1]) {
|
||||
return result(source.replace(explicit[0], " "), Number(explicit[1]), explicit[0].trim());
|
||||
}
|
||||
|
||||
const padded = source.match(/\b(0{1,3}\d{1,4})\b\s*$/);
|
||||
if (padded?.[1]) {
|
||||
return result(source.slice(0, padded.index).trim(), Number(padded[1]), padded[1]);
|
||||
}
|
||||
|
||||
return result(source, null, null);
|
||||
}
|
||||
|
||||
function result(seriesTitle: string, volumeNumber: number | null, volumeLabel: string | null): SeriesVolume {
|
||||
const title = cleanSeriesTitle(seriesTitle);
|
||||
return {
|
||||
seriesTitle: title,
|
||||
normalizedSeriesTitle: normalizeSeriesTitle(title),
|
||||
volumeNumber: Number.isFinite(volumeNumber) && volumeNumber !== null ? volumeNumber : null,
|
||||
volumeLabel
|
||||
};
|
||||
}
|
||||
|
||||
function cleanSeriesSource(value: string): string {
|
||||
return value
|
||||
.replace(/\.[A-Za-z0-9]{2,5}$/g, " ")
|
||||
.replace(/\[[^\]]*\]/g, " ")
|
||||
.replace(/[._]+/g, " ")
|
||||
.replace(/\b(FRENCH|TRUEFRENCH|MULTI|CBZ|CBR|EPUB|PDF|eBook|ebook|scan|digital|retail)\b/gi, " ")
|
||||
.replace(/\b(e?bdz|Paprika\+?|emuleCenter(?:\.|\s+)net)\b/gi, " ")
|
||||
.replace(/[+]+/g, " ")
|
||||
.replace(/\s+-\s+/g, " ")
|
||||
.replace(/\s*-\s*$/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function cleanSeriesTitle(value: string): string {
|
||||
return (
|
||||
value
|
||||
.replace(/\([^)]*\)/g, " ")
|
||||
.replace(/\bby\s+[A-Za-z0-9À-ÖØ-öø-ÿ.' -]{2,80}$/i, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim() || "Untitled Series"
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSeriesTitle(value: string): string {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
44
apps/api/src/metadata/use-cases/normalize-published-date.ts
Normal file
44
apps/api/src/metadata/use-cases/normalize-published-date.ts
Normal file
@ -0,0 +1,44 @@
|
||||
const minimumYear = 1500;
|
||||
const maximumYear = 2027;
|
||||
const rejectedExactDates = new Set(["0001-01-01", "0101-01-01", "1970-01-01"]);
|
||||
|
||||
export function normalizePublishedDate(value: string | null | undefined): string | null {
|
||||
const text = value?.trim();
|
||||
if (!text) return null;
|
||||
|
||||
const isoDate = text.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T\s].*)?$/);
|
||||
if (isoDate) {
|
||||
const [, year, month, day] = isoDate;
|
||||
const date = `${year}-${month}-${day}`;
|
||||
if (rejectedExactDates.has(date)) return null;
|
||||
return validDate(Number(year), Number(month), Number(day)) ? date : null;
|
||||
}
|
||||
|
||||
const yearMonth = text.match(/^(\d{4})-(\d{2})$/);
|
||||
if (yearMonth) {
|
||||
const [, year, month] = yearMonth;
|
||||
return validYear(Number(year)) && validMonth(Number(month)) ? `${year}-${month}` : null;
|
||||
}
|
||||
|
||||
const yearOnly = text.match(/^(\d{4})$/);
|
||||
if (yearOnly) {
|
||||
const year = Number(yearOnly[1]);
|
||||
return validYear(year) ? yearOnly[1] : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validDate(year: number, month: number, day: number): boolean {
|
||||
if (!validYear(year) || !validMonth(month) || day < 1 || day > 31) return false;
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||
}
|
||||
|
||||
function validYear(year: number): boolean {
|
||||
return Number.isInteger(year) && year >= minimumYear && year <= maximumYear;
|
||||
}
|
||||
|
||||
function validMonth(month: number): boolean {
|
||||
return Number.isInteger(month) && month >= 1 && month <= 12;
|
||||
}
|
||||
@ -3,56 +3,125 @@ import { MetadataMatch, MetadataSearchQuery } from "../metadata.types.js";
|
||||
export type ScoredMetadataMatch = {
|
||||
match: MetadataMatch;
|
||||
score: number;
|
||||
titleScore: number;
|
||||
authorScore: number | null;
|
||||
dateScore: number | null;
|
||||
isbnMatch: boolean;
|
||||
};
|
||||
|
||||
export class ScoreMetadataMatch {
|
||||
score(query: MetadataSearchQuery, match: MetadataMatch): number {
|
||||
let score = 0;
|
||||
const titleScore = similarity(normalize(query.title), normalize(match.title ?? ""));
|
||||
score += titleScore * 0.7;
|
||||
|
||||
if (query.author && match.author) {
|
||||
score += similarity(normalize(query.author), normalize(match.author)) * 0.2;
|
||||
} else if (!query.author) {
|
||||
score += 0.08;
|
||||
}
|
||||
|
||||
const queryYear = query.year ?? null;
|
||||
const matchYear = match.publishedDate?.match(/\b(1[5-9]\d{2}|20\d{2})\b/)?.[1] ?? null;
|
||||
if (queryYear && matchYear) score += queryYear === matchYear ? 0.1 : -0.1;
|
||||
|
||||
if (query.isbn && match.isbn && query.isbn.replace(/\D/g, "") === match.isbn.replace(/\D/g, "")) {
|
||||
score += 0.25;
|
||||
}
|
||||
return Math.max(0, Math.min(1, score));
|
||||
return this.details(query, match).score;
|
||||
}
|
||||
|
||||
best(query: MetadataSearchQuery, matches: MetadataMatch[], minimumScore = 0.55): ScoredMetadataMatch | null {
|
||||
details(query: MetadataSearchQuery, match: MetadataMatch): ScoredMetadataMatch {
|
||||
const titleScore = scoreTitle(query.title, match.scoreTitle ?? match.title ?? "");
|
||||
const authorScore = query.author ? scoreAuthor(query.author, match.author) : null;
|
||||
const dateScore = query.year ? scoreDate(query.year, match.publishedDate) : null;
|
||||
const isbnMatch = exactIsbnMatch(query.isbn, match.isbn);
|
||||
const maxPossible = 100 + (authorScore == null ? 0 : 30) + (dateScore == null ? 0 : 10);
|
||||
const sum = titleScore + (authorScore ?? 0) + (dateScore ?? 0);
|
||||
return {
|
||||
match,
|
||||
score: maxPossible ? Math.round((100 * sum) / maxPossible) : 0,
|
||||
titleScore,
|
||||
authorScore,
|
||||
dateScore,
|
||||
isbnMatch
|
||||
};
|
||||
}
|
||||
|
||||
best(query: MetadataSearchQuery, matches: MetadataMatch[], minimumScore = 0): ScoredMetadataMatch | null {
|
||||
const scored = matches
|
||||
.map((match) => ({ match, score: this.score(query, match) }))
|
||||
.sort((left, right) => right.score - left.score);
|
||||
.map((match) => this.details(query, match))
|
||||
.sort((left, right) => compareScoredMatches(query, left, right));
|
||||
const best = scored[0];
|
||||
return best && best.score >= minimumScore ? best : null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(value: string): string {
|
||||
function compareScoredMatches(query: MetadataSearchQuery, left: ScoredMetadataMatch, right: ScoredMetadataMatch): number {
|
||||
if (left.isbnMatch !== right.isbnMatch) return left.isbnMatch ? -1 : 1;
|
||||
const leftTitleAuthor = isHighConfidenceTitleAuthor(query, left);
|
||||
const rightTitleAuthor = isHighConfidenceTitleAuthor(query, right);
|
||||
if (leftTitleAuthor !== rightTitleAuthor) return leftTitleAuthor ? -1 : 1;
|
||||
return right.score - left.score;
|
||||
}
|
||||
|
||||
function isHighConfidenceTitleAuthor(query: MetadataSearchQuery, scored: ScoredMetadataMatch): boolean {
|
||||
return scored.titleScore > 90 && (!query.author || (scored.authorScore ?? 0) >= 15);
|
||||
}
|
||||
|
||||
function scoreTitle(left: string, right: string): number {
|
||||
const normalizedLeft = normalizeTitle(left);
|
||||
const normalizedRight = normalizeTitle(right);
|
||||
if (!normalizedLeft || !normalizedRight) return 0;
|
||||
if (normalizedLeft === normalizedRight) return 100;
|
||||
if (normalizedLeft.includes(normalizedRight) || normalizedRight.includes(normalizedLeft)) return 95;
|
||||
return Math.round(jaccard(tokens(normalizedLeft), tokens(normalizedRight)) * 100);
|
||||
}
|
||||
|
||||
function scoreAuthor(localAuthor: string, sourceAuthor: string | null | undefined): number {
|
||||
const local = authorSet(localAuthor);
|
||||
if (!local.size) return 0;
|
||||
const source = authorSet(sourceAuthor ?? "");
|
||||
const present = [...local].filter((author) => source.has(author)).length;
|
||||
return 30 * (present / local.size);
|
||||
}
|
||||
|
||||
function scoreDate(localYear: string, sourceDate: string | null | undefined): number {
|
||||
const left = Number(yearFrom(localYear));
|
||||
const right = Number(yearFrom(sourceDate ?? ""));
|
||||
if (!left || !right) return 0;
|
||||
if (left === right) return 10;
|
||||
return Math.abs(left - right) <= 1 ? 5 : 0;
|
||||
}
|
||||
|
||||
function exactIsbnMatch(left: string | null | undefined, right: string | null | undefined): boolean {
|
||||
const normalizedLeft = normalizeIsbn(left);
|
||||
const normalizedRight = normalizeIsbn(right);
|
||||
return Boolean(normalizedLeft && normalizedRight && normalizedLeft === normalizedRight);
|
||||
}
|
||||
|
||||
function normalizeTitle(value: string): string {
|
||||
return normalizeText(value.split(":")[0] ?? "").replace(/^(?:le|la|les|the|a|an|l)\s+/, "");
|
||||
}
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.replace(/\b(le|la|les|the|a|an|de|du|des|et|and)\b/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function similarity(left: string, right: string): number {
|
||||
if (!left || !right) return 0;
|
||||
if (left === right) return 1;
|
||||
const leftTokens = new Set(left.split(" "));
|
||||
const rightTokens = new Set(right.split(" "));
|
||||
function authorSet(value: string): Set<string> {
|
||||
return new Set(
|
||||
value
|
||||
.split(/[,;&/]|\band\b|\bet\b/gi)
|
||||
.map(normalizeText)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
);
|
||||
}
|
||||
|
||||
function tokens(value: string): Set<string> {
|
||||
return new Set(value.split(" ").filter(Boolean));
|
||||
}
|
||||
|
||||
function jaccard(leftTokens: Set<string>, rightTokens: Set<string>): number {
|
||||
const intersection = [...leftTokens].filter((token) => rightTokens.has(token)).length;
|
||||
const union = new Set([...leftTokens, ...rightTokens]).size;
|
||||
return union ? intersection / union : 0;
|
||||
}
|
||||
|
||||
function yearFrom(value: string): string | null {
|
||||
return value.match(/\b(1[5-9]\d{2}|20\d{2})\b/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function normalizeIsbn(value: string | null | undefined): string | null {
|
||||
const normalized = value?.replace(/[^0-9X]/gi, "").toUpperCase() ?? "";
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
@ -2,10 +2,15 @@ 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 { describe, expect, it, vi } from "vitest";
|
||||
import { listCbzImageEntries } from "../common/cbz.js";
|
||||
import { extractMetadata } from "./metadata.js";
|
||||
|
||||
vi.mock("../common/cbr.js", () => ({
|
||||
listCbrImageEntries: async () => [{ entryName: "001.jpg", name: "001.jpg" }],
|
||||
readCbrPage: async () => ({ entryName: "001.jpg", data: Buffer.from([0xff, 0xd8, 0xff, 0xd9]) })
|
||||
}));
|
||||
|
||||
describe("pdf metadata extraction", () => {
|
||||
it("falls back to file name and reads simple PDF info fields", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
|
||||
@ -36,3 +41,16 @@ describe("cbz metadata extraction", () => {
|
||||
expect(pages.map((page) => page.name)).toEqual(["001.jpg", "002.jpg"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cbr metadata extraction", () => {
|
||||
it("uses the file name as title and first extracted image as cover", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
|
||||
const file = join(dir, "Comic Two.cbr");
|
||||
writeFileSync(file, "rar");
|
||||
|
||||
const metadata = await extractMetadata(file, dir);
|
||||
|
||||
expect(metadata.title).toBe("Comic Two");
|
||||
expect(metadata.coverPath).toMatch(/covers\/[a-f0-9]+\.jpg$/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,8 +3,9 @@ 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 } from "../common/cbr.js";
|
||||
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
|
||||
import { listCbzImageEntries } from "../common/cbz.js";
|
||||
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
|
||||
|
||||
export type BookMetadata = {
|
||||
title: string;
|
||||
@ -64,7 +65,7 @@ function extractEpubMetadata(filePath: string, storageDir: string): BookMetadata
|
||||
isbn,
|
||||
language: firstText(metadata["dc:language"]),
|
||||
publisher: firstText(metadata["dc:publisher"]),
|
||||
publishedDate: firstText(metadata["dc:date"]),
|
||||
publishedDate: normalizePublishedDate(firstText(metadata["dc:date"])),
|
||||
coverPath
|
||||
};
|
||||
}
|
||||
@ -97,10 +98,12 @@ function extractCbzMetadata(filePath: string, storageDir: string): BookMetadata
|
||||
}
|
||||
|
||||
async function extractCbrMetadata(filePath: string, storageDir: string): Promise<BookMetadata> {
|
||||
await listCbrImageEntries(filePath);
|
||||
const firstPage = (await listCbrImageEntries(filePath))[0];
|
||||
const page = await readCbrPage(filePath, 1, storageDir);
|
||||
const coverPath = writeCoverData(page.data, firstPage.entryName, filePath, storageDir);
|
||||
return {
|
||||
...fallbackMetadata(filePath),
|
||||
coverPath: null
|
||||
coverPath
|
||||
};
|
||||
}
|
||||
|
||||
@ -165,6 +168,15 @@ function extractCover(zip: AdmZip, coverPathInZip: string, filePath: string, sto
|
||||
return target;
|
||||
}
|
||||
|
||||
function writeCoverData(data: Buffer, entryName: string, filePath: string, storageDir: string): string {
|
||||
const extension = extname(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, data);
|
||||
return target;
|
||||
}
|
||||
|
||||
function matchPdfInfo(text: string, key: string): string | null {
|
||||
return text.match(new RegExp(`/${key}\\s*\\(([^)]{1,500})\\)`))?.[1] ?? null;
|
||||
}
|
||||
|
||||
@ -1,5 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { scanDigest } from "./scanner.service.js";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { books, libraries } from "../database/schema.js";
|
||||
import { JobsService } from "../jobs/jobs.service.js";
|
||||
import { enrichmentDigest, preserveExistingBookValues, scanDigest } from "./scanner.service.js";
|
||||
import { ScannerService } from "./scanner.service.js";
|
||||
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const previousStorageDir = process.env.STORAGE_DIR;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
process.env.DATABASE_PATH = previousDatabasePath;
|
||||
process.env.STORAGE_DIR = previousStorageDir;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("scan digest", () => {
|
||||
it("reports incomplete files without exposing huge traces", () => {
|
||||
@ -17,4 +36,217 @@ describe("scan digest", () => {
|
||||
expect(detail).toContain("broken.cbr");
|
||||
expect(detail.length).toBeLessThan(380);
|
||||
});
|
||||
|
||||
it("reports metadata enrichment jobs as enrichment, not scans", () => {
|
||||
expect(enrichmentDigest(35, [])).toBe("Enriched 35 book(s)");
|
||||
});
|
||||
|
||||
it("does not erase existing metadata or cover when a rescan has less information", () => {
|
||||
const existing: typeof books.$inferSelect = {
|
||||
id: 1,
|
||||
libraryId: 1,
|
||||
seriesId: null,
|
||||
title: "Daredevil",
|
||||
author: "Roy Thomas",
|
||||
description: "Existing description",
|
||||
isbn: "9782809476255",
|
||||
isbn13: "9782809476255",
|
||||
identifiersJson: null,
|
||||
localMetadataJson: null,
|
||||
language: "fre",
|
||||
publisher: "Panini comics",
|
||||
publishedDate: "2019",
|
||||
volumeNumber: null,
|
||||
volumeLabel: null,
|
||||
format: "cbz",
|
||||
filePath: "/library/Daredevil.cbz",
|
||||
coverPath: "/storage/covers/daredevil.jpg",
|
||||
metadataStatus: "enriched",
|
||||
metadataProvenanceJson: JSON.stringify({ author: "bnf", coverPath: "openlibrary" }),
|
||||
scanStatus: "succeeded",
|
||||
enrichmentStatus: "succeeded",
|
||||
fileSize: 12,
|
||||
fileMtime: "2026-08-23T00:00:00.000Z",
|
||||
createdAt: "2026-08-23T00:00:00.000Z",
|
||||
updatedAt: "2026-08-23T00:00:00.000Z"
|
||||
};
|
||||
|
||||
const next = preserveExistingBookValues(
|
||||
{
|
||||
title: "Daredevil",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
coverPath: null,
|
||||
metadataStatus: "none",
|
||||
metadataProvenanceJson: JSON.stringify({ title: "local" }),
|
||||
scanStatus: "succeeded" as const
|
||||
},
|
||||
existing
|
||||
);
|
||||
|
||||
expect(next).toMatchObject({
|
||||
author: "Roy Thomas",
|
||||
description: "Existing description",
|
||||
isbn: "9782809476255",
|
||||
coverPath: "/storage/covers/daredevil.jpg",
|
||||
metadataStatus: "enriched"
|
||||
});
|
||||
expect(JSON.parse(String(next.metadataProvenanceJson))).toMatchObject({
|
||||
title: "local",
|
||||
author: "bnf",
|
||||
coverPath: "openlibrary"
|
||||
});
|
||||
});
|
||||
|
||||
it("does not replace an existing valid publication date with a sentinel date", () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
libraryId: 1,
|
||||
seriesId: null,
|
||||
title: "Lord of the Mysteries",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
identifiersJson: null,
|
||||
localMetadataJson: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: "2018",
|
||||
volumeNumber: null,
|
||||
volumeLabel: null,
|
||||
format: "epub",
|
||||
filePath: "/library/Lord of the Mysteries.epub",
|
||||
coverPath: null,
|
||||
metadataStatus: "partial",
|
||||
metadataProvenanceJson: JSON.stringify({ publishedDate: "existing" }),
|
||||
scanStatus: "succeeded",
|
||||
enrichmentStatus: "succeeded",
|
||||
fileSize: 12,
|
||||
fileMtime: "2026-08-23T00:00:00.000Z",
|
||||
createdAt: "2026-08-23T00:00:00.000Z",
|
||||
updatedAt: "2026-08-23T00:00:00.000Z"
|
||||
} satisfies typeof books.$inferSelect;
|
||||
|
||||
const next = preserveExistingBookValues(
|
||||
{
|
||||
title: "Lord of the Mysteries",
|
||||
publishedDate: "0101-01-01T00:00:00+00:00",
|
||||
metadataStatus: "partial",
|
||||
metadataProvenanceJson: JSON.stringify({ title: "local", publishedDate: "openlibrary" })
|
||||
},
|
||||
existing
|
||||
);
|
||||
|
||||
expect(next.publishedDate).toBe("2018");
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("updates the existing book when an insert races with books.file_path uniqueness", () => {
|
||||
const database = createDatabase();
|
||||
const now = database.now();
|
||||
const library = database.db
|
||||
.insert(libraries)
|
||||
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
|
||||
.returning()
|
||||
.get();
|
||||
database.db
|
||||
.insert(books)
|
||||
.values({
|
||||
libraryId: library.id,
|
||||
seriesId: null,
|
||||
title: "Daredevil",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
identifiersJson: null,
|
||||
localMetadataJson: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
volumeNumber: null,
|
||||
volumeLabel: null,
|
||||
format: "cbz",
|
||||
filePath: "/library/Daredevil.cbz",
|
||||
coverPath: null,
|
||||
metadataStatus: "none",
|
||||
metadataProvenanceJson: null,
|
||||
scanStatus: "succeeded",
|
||||
enrichmentStatus: "succeeded",
|
||||
fileSize: 1,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.run();
|
||||
const scanner = new ScannerService(database, new JobsService(database), {} as never);
|
||||
const values: Omit<typeof books.$inferInsert, "createdAt"> = {
|
||||
libraryId: library.id,
|
||||
seriesId: null,
|
||||
title: "Daredevil",
|
||||
author: "Roy Thomas",
|
||||
description: "Updated metadata",
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
identifiersJson: null,
|
||||
localMetadataJson: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
volumeNumber: null,
|
||||
volumeLabel: null,
|
||||
format: "cbz",
|
||||
filePath: "/library/Daredevil.cbz",
|
||||
coverPath: "/storage/covers/daredevil.jpg",
|
||||
metadataStatus: "enriched",
|
||||
metadataProvenanceJson: JSON.stringify({ author: "bnf", coverPath: "local" }),
|
||||
scanStatus: "succeeded",
|
||||
enrichmentStatus: "succeeded",
|
||||
fileSize: 2,
|
||||
fileMtime: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
(scanner as unknown as {
|
||||
upsertBookByFilePath(values: Omit<typeof books.$inferInsert, "createdAt">, existing: undefined, createdAt: string): void;
|
||||
}).upsertBookByFilePath(
|
||||
values,
|
||||
undefined,
|
||||
now
|
||||
);
|
||||
|
||||
const rows = database.db.select().from(books).all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
author: "Roy Thomas",
|
||||
coverPath: "/storage/covers/daredevil.jpg",
|
||||
metadataStatus: "enriched",
|
||||
fileSize: 2
|
||||
});
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
});
|
||||
|
||||
function createDatabase(): DatabaseService {
|
||||
const dir = mkdtempSync(join(tmpdir(), "readabook-scanner-service-"));
|
||||
tempDirs.push(dir);
|
||||
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
|
||||
process.env.STORAGE_DIR = join(dir, "storage");
|
||||
return new DatabaseService();
|
||||
}
|
||||
|
||||
function canLoadBetterSqlite(): boolean {
|
||||
try {
|
||||
const database = createDatabase();
|
||||
database.onModuleDestroy();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,9 +3,11 @@ import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { automationSettings, books, libraries } from "../database/schema.js";
|
||||
import { automationSettings, books, libraries, series } from "../database/schema.js";
|
||||
import { JobsService } from "../jobs/jobs.service.js";
|
||||
import { MetadataService } from "../metadata/metadata.service.js";
|
||||
import { extractSeriesVolume } from "../metadata/use-cases/extract-series-volume.js";
|
||||
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
|
||||
import { extractMetadata } from "./metadata.js";
|
||||
|
||||
@Injectable()
|
||||
@ -86,23 +88,40 @@ export class ScannerService {
|
||||
this.jobs.markRunning(jobId, "Enriching existing books");
|
||||
const rows = this.database.db.select({ id: books.id }).from(books).all();
|
||||
let count = 0;
|
||||
const failures: ScanFailure[] = [];
|
||||
for (const row of rows) {
|
||||
await this.metadata.enrichBook(row.id);
|
||||
count += 1;
|
||||
this.markBookEnrichmentStatus(row.id, "running");
|
||||
try {
|
||||
await this.metadata.enrichBook(row.id);
|
||||
this.markBookEnrichmentStatus(row.id, "succeeded");
|
||||
count += 1;
|
||||
} catch (error) {
|
||||
this.markBookEnrichmentStatus(row.id, "failed");
|
||||
failures.push({ filePath: `book #${row.id}`, error: errorMessage(error) });
|
||||
}
|
||||
}
|
||||
this.jobs.markSucceeded(jobId, `Enriched ${count} book(s)`);
|
||||
this.jobs.markSucceeded(jobId, enrichmentDigest(count, failures));
|
||||
}
|
||||
|
||||
private async ingestFile(libraryId: number, filePath: string): Promise<void> {
|
||||
const stats = statSync(filePath);
|
||||
const existing = this.database.db.select().from(books).where(eq(books.filePath, filePath)).get();
|
||||
if (existing) {
|
||||
this.database.db
|
||||
.update(books)
|
||||
.set({ scanStatus: "running", enrichmentStatus: "running", updatedAt: this.database.now() })
|
||||
.where(eq(books.id, existing.id))
|
||||
.run();
|
||||
}
|
||||
const localMetadata = await extractMetadata(filePath, this.database.config.storageDir);
|
||||
const now = this.database.now();
|
||||
const format = bookFormatFromPath(filePath);
|
||||
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get();
|
||||
const shouldRemoteEnrich = !existing ? this.shouldAutoEnrichNewBooks() : true;
|
||||
const metadata = await this.metadata.enrichMetadata(localMetadata, filePath, { remote: shouldRemoteEnrich });
|
||||
const seriesInfo = this.resolveSeries(metadata.title, filePath);
|
||||
const values = {
|
||||
libraryId,
|
||||
seriesId: seriesInfo.seriesId,
|
||||
title: metadata.title,
|
||||
author: metadata.author,
|
||||
description: metadata.description,
|
||||
@ -113,25 +132,31 @@ export class ScannerService {
|
||||
language: metadata.language,
|
||||
publisher: metadata.publisher,
|
||||
publishedDate: metadata.publishedDate,
|
||||
volumeNumber: seriesInfo.volumeNumber,
|
||||
volumeLabel: seriesInfo.volumeLabel,
|
||||
format,
|
||||
filePath,
|
||||
coverPath: metadata.coverPath,
|
||||
metadataStatus: metadata.metadataStatus,
|
||||
metadataProvenanceJson: metadata.metadataProvenanceJson,
|
||||
scanStatus: "succeeded" as const,
|
||||
enrichmentStatus: shouldRemoteEnrich ? ("succeeded" as const) : ("idle" as const),
|
||||
fileSize: stats.size,
|
||||
fileMtime: stats.mtime.toISOString(),
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
existing
|
||||
? this.database.db.update(books).set(values).where(eq(books.id, existing.id)).returning().get()
|
||||
: this.database.db.insert(books).values({ ...values, createdAt: now }).returning().get();
|
||||
this.upsertBookByFilePath(values, existing, now);
|
||||
}
|
||||
|
||||
private ingestIncompleteFile(libraryId: number, filePath: string): void {
|
||||
const stats = statSync(filePath);
|
||||
const now = this.database.now();
|
||||
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get();
|
||||
const existing = this.database.db.select().from(books).where(eq(books.filePath, filePath)).get();
|
||||
const seriesInfo = this.resolveSeries(basename(filePath, extname(filePath)), filePath);
|
||||
const values = {
|
||||
libraryId,
|
||||
seriesId: seriesInfo.seriesId,
|
||||
title: basename(filePath, extname(filePath)),
|
||||
author: null,
|
||||
description: null,
|
||||
@ -154,17 +179,41 @@ export class ScannerService {
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
volumeNumber: seriesInfo.volumeNumber,
|
||||
volumeLabel: seriesInfo.volumeLabel,
|
||||
format: bookFormatFromPath(filePath),
|
||||
filePath,
|
||||
coverPath: null,
|
||||
metadataStatus: "none" as const,
|
||||
metadataProvenanceJson: JSON.stringify({ title: "local" }),
|
||||
scanStatus: "failed" as const,
|
||||
enrichmentStatus: "failed" as const,
|
||||
fileSize: stats.size,
|
||||
fileMtime: stats.mtime.toISOString(),
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
existing
|
||||
? this.database.db.update(books).set(values).where(eq(books.id, existing.id)).returning().get()
|
||||
: this.database.db.insert(books).values({ ...values, createdAt: now }).returning().get();
|
||||
this.upsertBookByFilePath(values, existing, now);
|
||||
}
|
||||
|
||||
private upsertBookByFilePath(
|
||||
values: Omit<typeof books.$inferInsert, "createdAt">,
|
||||
existing: typeof books.$inferSelect | undefined,
|
||||
createdAt: string
|
||||
): void {
|
||||
if (existing) {
|
||||
this.database.db.update(books).set(preserveExistingBookValues(values, existing)).where(eq(books.id, existing.id)).run();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.database.db.insert(books).values({ ...values, createdAt }).run();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isUniqueFilePathError(error)) throw error;
|
||||
const current = this.database.db.select().from(books).where(eq(books.filePath, values.filePath)).get();
|
||||
if (!current) throw error;
|
||||
this.database.db.update(books).set(preserveExistingBookValues(values, current)).where(eq(books.id, current.id)).run();
|
||||
}
|
||||
}
|
||||
|
||||
private removeMissingBooks(libraryId: number, seen: Set<string>): number {
|
||||
@ -184,6 +233,32 @@ export class ScannerService {
|
||||
.get()?.autoEnrichNewBooks
|
||||
);
|
||||
}
|
||||
|
||||
private markBookEnrichmentStatus(id: number, enrichmentStatus: "running" | "succeeded" | "failed"): void {
|
||||
this.database.db.update(books).set({ enrichmentStatus, updatedAt: this.database.now() }).where(eq(books.id, id)).run();
|
||||
}
|
||||
|
||||
private resolveSeries(title: string, filePath: string): { seriesId: number; volumeNumber: number | null; volumeLabel: string | null } {
|
||||
const parsed = extractSeriesVolume(title, filePath);
|
||||
const now = this.database.now();
|
||||
const row = this.database.db
|
||||
.insert(series)
|
||||
.values({
|
||||
title: parsed.seriesTitle,
|
||||
normalizedTitle: parsed.normalizedSeriesTitle,
|
||||
description: null,
|
||||
publisher: null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: series.normalizedTitle,
|
||||
set: { title: parsed.seriesTitle, updatedAt: now }
|
||||
})
|
||||
.returning({ id: series.id })
|
||||
.get();
|
||||
return { seriesId: row.id, volumeNumber: parsed.volumeNumber, volumeLabel: parsed.volumeLabel };
|
||||
}
|
||||
}
|
||||
|
||||
type ScanFailure = {
|
||||
@ -202,6 +277,17 @@ export function scanDigest(scanned: number, removed: number, failures: ScanFailu
|
||||
return `${base}, ${failures.length} incomplete file(s): ${examples}${extra}`;
|
||||
}
|
||||
|
||||
export function enrichmentDigest(enriched: number, failures: ScanFailure[]): string {
|
||||
const base = `Enriched ${enriched} book(s)`;
|
||||
if (!failures.length) return base;
|
||||
const examples = failures
|
||||
.slice(0, 3)
|
||||
.map((failure) => `${basename(failure.filePath)}: ${truncate(failure.error)}`)
|
||||
.join("; ");
|
||||
const extra = failures.length > 3 ? `; ${failures.length - 3} more` : "";
|
||||
return `${base}, ${failures.length} incomplete book(s): ${examples}${extra}`;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return truncate(error.message);
|
||||
return truncate(String(error));
|
||||
@ -233,3 +319,49 @@ function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" | "cbr" {
|
||||
if (extension === ".cbr") return "cbr";
|
||||
return "pdf";
|
||||
}
|
||||
|
||||
export function preserveExistingBookValues<T extends Partial<typeof books.$inferInsert>>(values: T, existing: typeof books.$inferSelect): T {
|
||||
const next = { ...values };
|
||||
if (next.scanStatus === "failed" && existing.title) {
|
||||
next.title = existing.title as never;
|
||||
}
|
||||
for (const field of ["author", "description", "isbn", "isbn13", "language", "publisher", "publishedDate", "coverPath", "seriesId", "volumeNumber", "volumeLabel"] as const) {
|
||||
if (field === "publishedDate") {
|
||||
next.publishedDate = (normalizePublishedDate(next.publishedDate) ?? normalizePublishedDate(existing.publishedDate)) as never;
|
||||
continue;
|
||||
}
|
||||
if (next[field] == null && existing[field] != null) {
|
||||
next[field] = existing[field] as never;
|
||||
}
|
||||
}
|
||||
next.metadataStatus = computeMetadataStatus(next, existing.metadataStatus) as never;
|
||||
next.metadataProvenanceJson = mergeProvenanceJson(String(next.metadataProvenanceJson ?? "{}"), existing.metadataProvenanceJson) as never;
|
||||
return next;
|
||||
}
|
||||
|
||||
function computeMetadataStatus(values: Partial<typeof books.$inferInsert>, existingStatus: string): "enriched" | "partial" | "none" {
|
||||
const hasCover = Boolean(values.coverPath);
|
||||
const filled = [values.author, values.description, values.isbn, values.language, values.publisher, values.publishedDate].filter(Boolean).length;
|
||||
const computed = hasCover && filled >= 2 ? "enriched" : hasCover || filled > 0 ? "partial" : "none";
|
||||
const rank = { none: 0, partial: 1, enriched: 2 } as const;
|
||||
const safeExisting = existingStatus === "enriched" || existingStatus === "partial" || existingStatus === "none" ? existingStatus : "none";
|
||||
return rank[computed] >= rank[safeExisting] ? computed : safeExisting;
|
||||
}
|
||||
|
||||
function mergeProvenanceJson(nextJson: string, existingJson: string | null): string {
|
||||
return JSON.stringify({ ...parseJsonObject(existingJson), ...parseJsonObject(nextJson) });
|
||||
}
|
||||
|
||||
function parseJsonObject(value: string | null): Record<string, unknown> {
|
||||
if (!value) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueFilePathError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes("UNIQUE constraint failed: books.file_path");
|
||||
}
|
||||
|
||||
@ -40,6 +40,12 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location ^~ /series {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location ^~ /progress {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@ -12,6 +12,7 @@ import { LoginPage } from "./pages/LoginPage";
|
||||
import { ProfilePage } from "./pages/ProfilePage";
|
||||
import { ReaderPage } from "./pages/ReaderPage";
|
||||
import { SearchPage } from "./pages/SearchPage";
|
||||
import { SeriesPage } from "./pages/SeriesPage";
|
||||
import { SetupPage } from "./pages/SetupPage";
|
||||
import { navigate, parseRoute, type Route } from "./router";
|
||||
|
||||
@ -24,6 +25,8 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
|
||||
<HomePage />
|
||||
) : route.name === "library" ? (
|
||||
<LibraryPage libraryId={route.libraryId} />
|
||||
) : route.name === "catalogSeries" ? (
|
||||
<SeriesPage seriesName={route.seriesName} />
|
||||
) : route.name === "book" ? (
|
||||
<BookPage bookId={route.bookId} />
|
||||
) : route.name === "reader" ? (
|
||||
|
||||
@ -3,6 +3,25 @@ import type { ContinueItem } from "./types";
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
type BookPipelineStatus = "idle" | "running" | "succeeded" | "failed";
|
||||
type BookMetadataStatus = "enriched" | "partial" | "none";
|
||||
type MockBookDto = Omit<BookDto, "metadataStatus" | "metadataProvenance" | "scanStatus" | "enrichmentStatus"> & {
|
||||
metadataStatus?: BookMetadataStatus;
|
||||
metadataProvenance?: Record<string, string>;
|
||||
scanStatus?: BookPipelineStatus;
|
||||
enrichmentStatus?: BookPipelineStatus;
|
||||
};
|
||||
|
||||
function mockBook(book: MockBookDto): BookDto {
|
||||
return {
|
||||
metadataStatus: "partial",
|
||||
metadataProvenance: { local: "fixture" },
|
||||
scanStatus: "idle",
|
||||
enrichmentStatus: "idle",
|
||||
...book
|
||||
} as BookDto;
|
||||
}
|
||||
|
||||
export const mockUser: UserDto = {
|
||||
id: 1,
|
||||
email: "admin@readabook.local",
|
||||
@ -17,7 +36,7 @@ export const mockLibraries: LibraryDto[] = [
|
||||
];
|
||||
|
||||
export const mockBooks: BookDto[] = [
|
||||
{
|
||||
mockBook({
|
||||
id: 1,
|
||||
libraryId: 1,
|
||||
title: "L'Herbier des machines",
|
||||
@ -35,8 +54,8 @@ export const mockBooks: BookDto[] = [
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
}),
|
||||
mockBook({
|
||||
id: 2,
|
||||
libraryId: 2,
|
||||
title: "Cartographie des songes",
|
||||
@ -54,8 +73,8 @@ export const mockBooks: BookDto[] = [
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
}),
|
||||
mockBook({
|
||||
id: 3,
|
||||
libraryId: 2,
|
||||
title: "Les vitrines de verre",
|
||||
@ -73,8 +92,8 @@ export const mockBooks: BookDto[] = [
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
}),
|
||||
mockBook({
|
||||
id: 4,
|
||||
libraryId: 2,
|
||||
title: "Cabinet noir",
|
||||
@ -92,7 +111,7 @@ export const mockBooks: BookDto[] = [
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
})
|
||||
];
|
||||
|
||||
export const mockProgress: ProgressDto[] = [
|
||||
|
||||
@ -36,3 +36,17 @@ export type ReaderPreferencesDto = {
|
||||
mode: ReaderMode;
|
||||
fit?: ReaderFit;
|
||||
};
|
||||
|
||||
export function hasActiveCoverWork(jobs: JobDto[]) {
|
||||
return jobs.some((job) => {
|
||||
if (job.status !== "queued" && job.status !== "running") return false;
|
||||
const type = job.type.toLowerCase();
|
||||
return type.includes("scan") || type.includes("enrich") || type.includes("metadata") || type.includes("cover");
|
||||
});
|
||||
}
|
||||
|
||||
export function isBookCoverUpdating(book: BookDto, fallbackActive = false) {
|
||||
const statuses = book as BookDto & { scanStatus?: string; enrichmentStatus?: string };
|
||||
if (statuses.scanStatus === "running" || statuses.enrichmentStatus === "running") return true;
|
||||
return statuses.scanStatus === undefined && statuses.enrichmentStatus === undefined && fallbackActive;
|
||||
}
|
||||
|
||||
131
apps/web/src/book/metadata.test.ts
Normal file
131
apps/web/src/book/metadata.test.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import {
|
||||
bookCardVolumeLabel,
|
||||
bookDisplayTitle,
|
||||
bookMetadataSourceSummary,
|
||||
bookMetadataStateLabel,
|
||||
bookSeriesInfo,
|
||||
bookSeriesLabel,
|
||||
bookVolumeLabel,
|
||||
displayPublishedDate,
|
||||
jobDigestSummary
|
||||
} from "./metadata";
|
||||
|
||||
type BookFixture = BookDto & {
|
||||
metadataStatus: "enriched" | "partial" | "none";
|
||||
metadataProvenance: Record<string, string>;
|
||||
scanStatus: "idle" | "running" | "succeeded" | "failed";
|
||||
enrichmentStatus: "idle" | "running" | "succeeded" | "failed";
|
||||
};
|
||||
|
||||
const baseBook: BookFixture = {
|
||||
id: 1,
|
||||
libraryId: 1,
|
||||
title: "Livre test",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
format: "epub",
|
||||
filePath: "/books/test.epub",
|
||||
coverPath: null,
|
||||
metadataStatus: "none",
|
||||
metadataProvenance: {},
|
||||
scanStatus: "idle",
|
||||
enrichmentStatus: "idle",
|
||||
fileSize: 1,
|
||||
fileMtime: "2026-08-23T00:00:00.000Z",
|
||||
createdAt: "2026-08-23T00:00:00.000Z",
|
||||
updatedAt: "2026-08-23T00:00:00.000Z"
|
||||
};
|
||||
|
||||
describe("book metadata presentation", () => {
|
||||
it("labels externally enriched books", () => {
|
||||
const book: BookFixture = { ...baseBook, metadataStatus: "enriched", enrichmentStatus: "succeeded" };
|
||||
expect(bookMetadataStateLabel(book)).toBe("enrichi");
|
||||
expect(bookMetadataSourceSummary(book)).toBe("source locale + enrichissement externe");
|
||||
});
|
||||
|
||||
it("labels locally discovered metadata as partial", () => {
|
||||
const book: BookFixture = { ...baseBook, metadataStatus: "partial", author: "Ada", publishedDate: "1998", scanStatus: "succeeded" };
|
||||
expect(bookMetadataStateLabel(book)).toBe("partiel");
|
||||
expect(bookMetadataSourceSummary(book)).toBe("source locale uniquement");
|
||||
});
|
||||
|
||||
it("labels books without exploitable metadata as missing", () => {
|
||||
expect(bookMetadataStateLabel(baseBook)).toBe("non enrichi");
|
||||
expect(bookMetadataSourceSummary(baseBook)).toBe("metadata indisponible");
|
||||
});
|
||||
|
||||
it("accepts optional series fields when the backend exposes them", () => {
|
||||
const book = { ...baseBook, title: "Nom fichier", series: "Cycle", volumeNumber: 2 } as BookDto & { series: string; volumeNumber: number };
|
||||
expect(bookSeriesLabel(book)).toBe("Cycle · Volume 2");
|
||||
expect(bookDisplayTitle(book)).toBe("Cycle");
|
||||
expect(bookVolumeLabel(book)).toBe("Volume 2");
|
||||
});
|
||||
|
||||
it("uses backend series objects and normalized backend volume labels", () => {
|
||||
const book = {
|
||||
...baseBook,
|
||||
title: "Daredevil",
|
||||
series: { id: 1, title: "Daredevil", normalizedTitle: "daredevil", description: null, publisher: null, createdAt: baseBook.createdAt, updatedAt: baseBook.updatedAt },
|
||||
volumeNumber: 1,
|
||||
volumeLabel: "001"
|
||||
} as BookDto;
|
||||
expect(bookDisplayTitle(book)).toBe("Daredevil");
|
||||
expect(bookVolumeLabel(book)).toBe("#1");
|
||||
expect(bookSeriesLabel(book)).toBe("Daredevil · #1");
|
||||
});
|
||||
|
||||
it("uses compact and unambiguous volume labels on book cards", () => {
|
||||
expect(bookCardVolumeLabel({ ...baseBook, title: "Solo Leveling T03" })).toBe("T. 3");
|
||||
expect(bookCardVolumeLabel({ ...baseBook, title: "Archive Volume 12" })).toBe("T. 12");
|
||||
expect(bookCardVolumeLabel({ ...baseBook, title: "Daredevil #6" })).toBe("#6");
|
||||
});
|
||||
|
||||
it("hides book card volume labels when the number is absent or ambiguous", () => {
|
||||
const ambiguousBook = { ...baseBook, title: "Nom fichier", series: "Cycle", volumeLabel: "Tome final" } as BookDto & { series: string; volumeLabel: string };
|
||||
expect(bookCardVolumeLabel(ambiguousBook)).toBeNull();
|
||||
expect(bookCardVolumeLabel({ ...baseBook, title: "Livre sans tome" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps admin job digest synthetic", () => {
|
||||
expect(
|
||||
jobDigestSummary({
|
||||
id: 1,
|
||||
type: "metadata-enrich",
|
||||
status: "succeeded",
|
||||
detail: null,
|
||||
error: null,
|
||||
createdAt: baseBook.createdAt,
|
||||
updatedAt: baseBook.updatedAt
|
||||
})
|
||||
).toBe("enrichissement externe");
|
||||
});
|
||||
|
||||
it("hides sentinel and absent publication dates", () => {
|
||||
expect(displayPublishedDate("0101-01-01T00:00:00+00:00")).toBeNull();
|
||||
expect(displayPublishedDate(null)).toBeNull();
|
||||
expect(displayPublishedDate("")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders only the credible publication year", () => {
|
||||
expect(displayPublishedDate("2007")).toBe("2007");
|
||||
expect(displayPublishedDate("2007-07-21T00:00:00+00:00")).toBe("2007");
|
||||
expect(displayPublishedDate("first published in 1998")).toBe("1998");
|
||||
});
|
||||
|
||||
it("normalizes flexible series and volume suffixes from titles", () => {
|
||||
expect(bookSeriesInfo({ ...baseBook, title: "Daredevil 001" })).toEqual({ title: "Daredevil", volumeLabel: "#1", volumeNumber: 1 });
|
||||
expect(bookSeriesInfo({ ...baseBook, title: "Daredevil #6" })).toEqual({ title: "Daredevil", volumeLabel: "#6", volumeNumber: 6 });
|
||||
expect(bookSeriesInfo({ ...baseBook, title: "Solo Leveling T03" })).toEqual({ title: "Solo Leveling", volumeLabel: "Tome 3", volumeNumber: 3 });
|
||||
expect(bookSeriesInfo({ ...baseBook, title: "Eyeshield 21 T02" })).toEqual({ title: "Eyeshield 21", volumeLabel: "Tome 2", volumeNumber: 2 });
|
||||
expect(bookSeriesInfo({ ...baseBook, title: "Archive Tome 3" })).toEqual({ title: "Archive", volumeLabel: "Tome 3", volumeNumber: 3 });
|
||||
expect(bookSeriesInfo({ ...baseBook, title: "Archive Volume 3" })).toEqual({ title: "Archive", volumeLabel: "Volume 3", volumeNumber: 3 });
|
||||
expect(bookSeriesInfo({ ...baseBook, title: "Archive Issue 6" })).toEqual({ title: "Archive", volumeLabel: "#6", volumeNumber: 6 });
|
||||
});
|
||||
});
|
||||
171
apps/web/src/book/metadata.ts
Normal file
171
apps/web/src/book/metadata.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import type { BookDto, JobDto } from "@readabook/shared";
|
||||
|
||||
export type BookMetadataState = "enriched" | "partial" | "missing";
|
||||
|
||||
const earliestCrediblePublishedYear = 1450;
|
||||
|
||||
export type BookSeriesInfo = {
|
||||
title: string;
|
||||
volumeLabel: string | null;
|
||||
volumeNumber: number | null;
|
||||
};
|
||||
|
||||
type ExtendedBookDto = BookDto & {
|
||||
scanStatus?: "idle" | "running" | "succeeded" | "failed";
|
||||
enrichmentStatus?: "idle" | "running" | "succeeded" | "failed";
|
||||
series?: string | { title?: string | null } | null;
|
||||
seriesTitle?: string | null;
|
||||
collection?: string | null;
|
||||
volumeLabel?: string | null;
|
||||
seriesIndex?: string | number | null;
|
||||
seriesNumber?: string | number | null;
|
||||
volume?: string | number | null;
|
||||
volumeNumber?: string | number | null;
|
||||
issue?: string | number | null;
|
||||
issueNumber?: string | number | null;
|
||||
};
|
||||
|
||||
function hasValue(value: unknown): value is string | number {
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
export function bookSeriesLabel(book: BookDto): string | null {
|
||||
const series = bookSeriesInfo(book);
|
||||
if (!series) return null;
|
||||
return [series.title, series.volumeLabel].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function numericValue(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value !== "string") return null;
|
||||
const match = value.trim().match(/\d+/);
|
||||
if (!match) return null;
|
||||
const parsed = Number(match[0]);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeVolumeLabel(value: unknown, fallbackKind: "tome" | "volume" | "issue" = "volume"): { label: string; number: number | null } | null {
|
||||
if (!hasValue(value)) return null;
|
||||
const raw = String(value).trim();
|
||||
const number = numericValue(raw);
|
||||
if (!number) return null;
|
||||
if (/^(t|tome)\s*0*\d+$/i.test(raw)) return { label: `Tome ${number}`, number };
|
||||
if (/^(vol\.?|volume)\s*0*\d+$/i.test(raw)) return { label: `Volume ${number}`, number };
|
||||
if (/^(#|issue)\s*0*\d+$/i.test(raw)) return { label: `#${number}`, number };
|
||||
if (/^0\d{2,}$/.test(raw)) return { label: `#${number}`, number };
|
||||
if (fallbackKind === "tome") return { label: `Tome ${number}`, number };
|
||||
if (fallbackKind === "issue") return { label: `#${number}`, number };
|
||||
return { label: `Volume ${number}`, number };
|
||||
}
|
||||
|
||||
function titleVolumeInfo(title: string): BookSeriesInfo | null {
|
||||
const trimmed = title.trim();
|
||||
const patterns: Array<{ pattern: RegExp; kind: "tome" | "volume" | "issue" }> = [
|
||||
{ pattern: /^(.+?)\s+(T|Tome)\s*0*(\d+)$/i, kind: "tome" },
|
||||
{ pattern: /^(.+?)\s+(Vol\.?|Volume)\s*0*(\d+)$/i, kind: "volume" },
|
||||
{ pattern: /^(.+?)\s+(#|Issue)\s*0*(\d+)$/i, kind: "issue" },
|
||||
{ pattern: /^(.+?)\s+0*(\d{3})$/i, kind: "issue" }
|
||||
];
|
||||
for (const { pattern, kind } of patterns) {
|
||||
const match = trimmed.match(pattern);
|
||||
if (!match) continue;
|
||||
const titlePart = match[1]?.trim();
|
||||
const number = Number(match[3] ?? match[2]);
|
||||
if (!titlePart || !Number.isFinite(number)) continue;
|
||||
const normalized = normalizeVolumeLabel(number, kind);
|
||||
if (!normalized) continue;
|
||||
return { title: titlePart, volumeLabel: normalized.label, volumeNumber: normalized.number };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function bookSeriesInfo(book: BookDto): BookSeriesInfo | null {
|
||||
const extended = book as ExtendedBookDto;
|
||||
const seriesObjectTitle =
|
||||
extended.series && typeof extended.series === "object" && hasValue(extended.series.title) ? extended.series.title : null;
|
||||
const series = [seriesObjectTitle, extended.series, extended.seriesTitle, extended.collection].find(hasValue);
|
||||
if (series) {
|
||||
const explicitLabel = normalizeVolumeLabel(extended.volumeLabel);
|
||||
const issue = normalizeVolumeLabel([extended.issueNumber, extended.issue, extended.seriesNumber].find(hasValue), "issue");
|
||||
const volume = normalizeVolumeLabel([extended.volumeNumber, extended.volume, extended.seriesIndex].find(hasValue), "volume");
|
||||
const position = explicitLabel ?? issue ?? volume;
|
||||
return {
|
||||
title: String(series),
|
||||
volumeLabel: position?.label ?? null,
|
||||
volumeNumber: position?.number ?? null
|
||||
};
|
||||
}
|
||||
return titleVolumeInfo(book.title);
|
||||
}
|
||||
|
||||
export function bookDisplayTitle(book: BookDto): string {
|
||||
return bookSeriesInfo(book)?.title ?? book.title;
|
||||
}
|
||||
|
||||
export function bookVolumeLabel(book: BookDto): string | null {
|
||||
return bookSeriesInfo(book)?.volumeLabel ?? null;
|
||||
}
|
||||
|
||||
export function bookCardVolumeLabel(book: BookDto): string | null {
|
||||
const series = bookSeriesInfo(book);
|
||||
if (!series?.volumeNumber) return null;
|
||||
if (!series.volumeLabel) return null;
|
||||
if (series.volumeLabel.startsWith("#")) return series.volumeLabel;
|
||||
return `T. ${series.volumeNumber}`;
|
||||
}
|
||||
|
||||
export function displayPublishedDate(value?: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const yearMatch = trimmed.match(/\b(\d{4})\b/);
|
||||
if (!yearMatch) return null;
|
||||
const year = Number(yearMatch[1]);
|
||||
const nextYear = new Date().getFullYear() + 1;
|
||||
if (!Number.isInteger(year) || year < earliestCrediblePublishedYear || year > nextYear) return null;
|
||||
return String(year);
|
||||
}
|
||||
|
||||
export function usefulMetadataCount(book: BookDto): number {
|
||||
return [
|
||||
book.author,
|
||||
displayPublishedDate(book.publishedDate),
|
||||
book.publisher,
|
||||
book.description,
|
||||
book.isbn13,
|
||||
book.isbn,
|
||||
book.coverPath,
|
||||
bookSeriesLabel(book)
|
||||
].filter(hasValue).length;
|
||||
}
|
||||
|
||||
export function bookMetadataState(book: BookDto): BookMetadataState {
|
||||
const statuses = book as ExtendedBookDto;
|
||||
if (statuses.enrichmentStatus === "succeeded") return "enriched";
|
||||
if (usefulMetadataCount(book) > 0 || statuses.scanStatus === "succeeded") return "partial";
|
||||
return "missing";
|
||||
}
|
||||
|
||||
export function bookMetadataStateLabel(book: BookDto): string {
|
||||
const state = bookMetadataState(book);
|
||||
if (state === "enriched") return "enrichi";
|
||||
if (state === "partial") return "partiel";
|
||||
return "non enrichi";
|
||||
}
|
||||
|
||||
export function bookMetadataSourceSummary(book: BookDto): string {
|
||||
const statuses = book as ExtendedBookDto;
|
||||
if (statuses.enrichmentStatus === "running" || statuses.scanStatus === "running") return "mise a jour en cours";
|
||||
if (statuses.enrichmentStatus === "succeeded") return "source locale + enrichissement externe";
|
||||
if (usefulMetadataCount(book) > 0 || statuses.scanStatus === "succeeded") return "source locale uniquement";
|
||||
return "metadata indisponible";
|
||||
}
|
||||
|
||||
export function jobDigestSummary(job: JobDto): string {
|
||||
const detail = job.detail?.trim();
|
||||
if (detail) return detail;
|
||||
if (job.type.toLowerCase().includes("enrich")) return "enrichissement externe";
|
||||
if (job.type.toLowerCase().includes("scan")) return "source locale";
|
||||
return "travail catalogue";
|
||||
}
|
||||
@ -1,22 +1,35 @@
|
||||
import { BookOpen, Eye } from "lucide-react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { bookCardVolumeLabel, bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, displayPublishedDate } from "../book/metadata";
|
||||
import { navigate } from "../router";
|
||||
import { FormatPill } from "./ui";
|
||||
|
||||
export function BookCard({ book, compact = false }: { book: BookDto; compact?: boolean }) {
|
||||
export function BookCard({ book, compact = false, coverLoading = false }: { book: BookDto; compact?: boolean; coverLoading?: boolean }) {
|
||||
const metadataState = bookMetadataState(book);
|
||||
const publishedDate = displayPublishedDate(book.publishedDate);
|
||||
const volumeLabel = bookCardVolumeLabel(book);
|
||||
|
||||
return (
|
||||
<article className={`book-card ${compact ? "book-card-compact" : ""}`}>
|
||||
<button className="cover-button" onClick={() => navigate(`/book/${book.id}`)} aria-label={`Ouvrir ${book.title}`}>
|
||||
{book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={34} />}
|
||||
{coverLoading && <span className="cover-loading" aria-label="Jaquette en cours de mise à jour" />}
|
||||
</button>
|
||||
<div className="book-card-body">
|
||||
<div className="book-card-meta">
|
||||
<FormatPill format={book.format} />
|
||||
<span>{book.language ?? "langue inconnue"}</span>
|
||||
{volumeLabel && <span className="volume-pill">{volumeLabel}</span>}
|
||||
<span className="book-card-language">{book.language ?? "langue inconnue"}</span>
|
||||
</div>
|
||||
<h3>{book.title}</h3>
|
||||
<h3>{bookDisplayTitle(book)}</h3>
|
||||
<p>{book.author ?? "Auteur inconnu"}</p>
|
||||
{(volumeLabel || publishedDate) && (
|
||||
<p className="book-card-submeta">
|
||||
{[volumeLabel, publishedDate].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
|
||||
{!compact && <p className="book-card-description">{book.description ?? "Notice absente du catalogue."}</p>}
|
||||
<div className="book-card-actions">
|
||||
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}>
|
||||
|
||||
@ -4,16 +4,21 @@ import type {
|
||||
AutomationFrequency,
|
||||
AutomationScheduleDto,
|
||||
AutomationSettingsDto,
|
||||
MetadataProviderId,
|
||||
MetadataSourcesConfigDto
|
||||
} from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { ErrorRibbon, LoadingState, Panel } from "../components/ui";
|
||||
import {
|
||||
type AdminMetadataProviderId,
|
||||
type AdminMetadataSourcesConfig,
|
||||
defaultMetadataSources,
|
||||
metadataSourcesPayload,
|
||||
moveSource,
|
||||
normalizeMetadataSources,
|
||||
providerLabels,
|
||||
providerUiMessage,
|
||||
providerUiState,
|
||||
providerUiStateLabel,
|
||||
scheduleDays,
|
||||
scheduleSummary
|
||||
} from "./adminAutomation";
|
||||
@ -28,14 +33,9 @@ type ApiState<T> = {
|
||||
success?: string;
|
||||
};
|
||||
|
||||
const defaultMetadataConfig: MetadataSourcesConfigDto = {
|
||||
const defaultMetadataConfig: AdminMetadataSourcesConfig = {
|
||||
isbnPriorityEnabled: true,
|
||||
sources: [
|
||||
{ provider: "local", enabled: true, priority: 0, hasApiKey: false },
|
||||
{ provider: "openlibrary", enabled: false, priority: 1, hasApiKey: false },
|
||||
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: false },
|
||||
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false }
|
||||
]
|
||||
sources: defaultMetadataSources
|
||||
};
|
||||
|
||||
const defaultAutomationSettings: AutomationSettingsDto = {
|
||||
@ -47,7 +47,7 @@ const defaultAutomationSettings: AutomationSettingsDto = {
|
||||
|
||||
export function AdminAutomationPage() {
|
||||
const [tab, setTab] = useState<AdminAutomationTab>("sources");
|
||||
const [metadataState, setMetadataState] = useState<ApiState<MetadataSourcesConfigDto>>({
|
||||
const [metadataState, setMetadataState] = useState<ApiState<AdminMetadataSourcesConfig>>({
|
||||
initial: null,
|
||||
draft: null,
|
||||
loading: true,
|
||||
@ -59,7 +59,7 @@ export function AdminAutomationPage() {
|
||||
loading: true,
|
||||
saving: false
|
||||
});
|
||||
const [apiKeys, setApiKeys] = useState<Partial<Record<MetadataProviderId, string>>>({});
|
||||
const [apiKeys, setApiKeys] = useState<Partial<Record<AdminMetadataProviderId, string>>>({});
|
||||
|
||||
const metadataDirty = useMemo(
|
||||
() => Boolean(metadataState.initial && metadataState.draft && JSON.stringify(metadataState.initial) !== JSON.stringify(metadataState.draft)),
|
||||
@ -78,7 +78,7 @@ export function AdminAutomationPage() {
|
||||
setMetadataState({ initial: next, draft: next, loading: false, saving: false });
|
||||
setApiKeys({});
|
||||
} catch (error) {
|
||||
const fallback = getApiFallback<MetadataSourcesConfigDto>(error);
|
||||
const fallback = getApiFallback<MetadataSourcesConfigDto | AdminMetadataSourcesConfig>(error);
|
||||
const next = normalizeMetadataSources(fallback ?? defaultMetadataConfig);
|
||||
setMetadataState({
|
||||
initial: next,
|
||||
@ -226,11 +226,11 @@ function MetadataSourcesPanel({
|
||||
onSubmit,
|
||||
onRefresh
|
||||
}: {
|
||||
state: ApiState<MetadataSourcesConfigDto>;
|
||||
state: ApiState<AdminMetadataSourcesConfig>;
|
||||
dirty: boolean;
|
||||
apiKeys: Partial<Record<MetadataProviderId, string>>;
|
||||
setApiKeys: (next: Partial<Record<MetadataProviderId, string>>) => void;
|
||||
onChange: (draft: MetadataSourcesConfigDto) => void;
|
||||
apiKeys: Partial<Record<AdminMetadataProviderId, string>>;
|
||||
setApiKeys: (next: Partial<Record<AdminMetadataProviderId, string>>) => void;
|
||||
onChange: (draft: AdminMetadataSourcesConfig) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onRefresh: () => Promise<void>;
|
||||
}) {
|
||||
@ -297,14 +297,25 @@ function MetadataSourcesPanel({
|
||||
<small>{source.enabled ? "active" : "inactive"}</small>
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
Cle API
|
||||
<input
|
||||
value={apiKeys[source.provider] ?? ""}
|
||||
onChange={(event) => setApiKeys({ ...apiKeys, [source.provider]: event.target.value })}
|
||||
placeholder={source.hasApiKey ? "cle conservee" : "optionnelle"}
|
||||
/>
|
||||
</label>
|
||||
<div className="provider-config">
|
||||
<div className="provider-state-line">
|
||||
<span className={`status-pill provider-state-${providerUiState(source)}`}>{providerUiStateLabel(source)}</span>
|
||||
<small>{providerUiMessage(source)}</small>
|
||||
</div>
|
||||
{source.provider === "comicvine" && (
|
||||
<p className="provider-warning">
|
||||
Usage non commercial uniquement. Verifier la compatibilite avec l'usage de ReadaBook.
|
||||
</p>
|
||||
)}
|
||||
<label>
|
||||
Cle API
|
||||
<input
|
||||
value={apiKeys[source.provider] ?? ""}
|
||||
onChange={(event) => setApiKeys({ ...apiKeys, [source.provider]: event.target.value })}
|
||||
placeholder={source.hasApiKey ? "cle conservee" : source.requiresCredentials ? "requise" : "optionnelle"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="provider-actions">
|
||||
<button
|
||||
className="ghost-button icon-button"
|
||||
|
||||
@ -2,8 +2,13 @@ import { FormEvent, useEffect, useState } from "react";
|
||||
import { Play, Plus, Trash2 } from "lucide-react";
|
||||
import type { JobDto, LibraryDto, UserDto } from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { jobDigestSummary } from "../book/metadata";
|
||||
import { EmptyState, ErrorRibbon, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
function formatJobTime(value: string) {
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(value));
|
||||
}
|
||||
|
||||
export function AdminPage() {
|
||||
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
|
||||
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||||
@ -161,7 +166,13 @@ export function AdminPage() {
|
||||
<div className="job-list">
|
||||
{jobs.map((job) => (
|
||||
<div key={job.id}>
|
||||
<strong>{job.type}</strong>
|
||||
<div className="job-copy">
|
||||
<div>
|
||||
<strong>{job.type}</strong>
|
||||
<small>{jobDigestSummary(job)}</small>
|
||||
</div>
|
||||
<time dateTime={job.updatedAt}>{formatJobTime(job.updatedAt)}</time>
|
||||
</div>
|
||||
<span>{job.status}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -3,6 +3,7 @@ import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
|
||||
import type { BookDto, ProgressDto } from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { cleanBookDescription } from "../book/description";
|
||||
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata";
|
||||
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
|
||||
@ -52,6 +53,18 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
const series = bookSeriesInfo(book);
|
||||
const metadataState = bookMetadataState(book);
|
||||
const publishedDate = displayPublishedDate(book.publishedDate);
|
||||
const detailFacts = [
|
||||
{ label: "Auteur", value: book.author },
|
||||
{ label: "Date", value: publishedDate },
|
||||
{ label: "Serie", value: series?.title },
|
||||
{ label: "Position", value: series?.volumeLabel },
|
||||
{ label: "Editeur", value: book.publisher },
|
||||
{ label: "ISBN", value: book.isbn13 ?? book.isbn }
|
||||
].filter((fact) => fact.value);
|
||||
|
||||
return (
|
||||
<div className="book-detail">
|
||||
<section className="book-portrait">
|
||||
@ -62,9 +75,18 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
<div className="book-card-meta">
|
||||
<FormatPill format={book.format} />
|
||||
<span>{book.language ?? "langue inconnue"}</span>
|
||||
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
|
||||
</div>
|
||||
<h1>{book.title}</h1>
|
||||
<h1>{bookDisplayTitle(book)}</h1>
|
||||
<p className="lead">{book.author ?? "Auteur inconnu"}</p>
|
||||
<dl className="book-fact-list">
|
||||
{detailFacts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="book-description">{cleanBookDescription(book.description)}</p>
|
||||
{progress && <Meter value={progress.percent} />}
|
||||
<div className="book-card-actions">
|
||||
@ -76,6 +98,12 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
<LibraryBig size={18} />
|
||||
Rayon
|
||||
</button>
|
||||
{series && (
|
||||
<button className="ghost-button" onClick={() => navigate(`/catalog/series/${encodeURIComponent(series.title)}`)}>
|
||||
<LibraryBig size={18} />
|
||||
Serie
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||
<RotateCcw size={18} />
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LibraryBig, ScanLine } from "lucide-react";
|
||||
import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
|
||||
import { api } from "../api/client";
|
||||
import type { DashboardData } from "../api/types";
|
||||
import { hasActiveCoverWork, isBookCoverUpdating, type DashboardData } from "../api/types";
|
||||
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookVolumeLabel, displayPublishedDate } from "../book/metadata";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
@ -27,6 +28,7 @@ export function HomePage() {
|
||||
}, []);
|
||||
|
||||
if (!state) return <LoadingState />;
|
||||
const fallbackCoverLoading = hasActiveCoverWork(state.jobs);
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
@ -49,13 +51,25 @@ export function HomePage() {
|
||||
</div>
|
||||
{state.continueReading.length ? (
|
||||
<div className="continue-grid">
|
||||
{state.continueReading.map((item) => (
|
||||
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}>
|
||||
<strong>{item.book.title}</strong>
|
||||
<span>{item.book.author ?? "Auteur inconnu"}</span>
|
||||
<Meter value={item.progress.percent} />
|
||||
</button>
|
||||
))}
|
||||
{state.continueReading.map((item) => {
|
||||
const metadataState = bookMetadataState(item.book);
|
||||
const publishedDate = displayPublishedDate(item.book.publishedDate);
|
||||
const volumeLabel = bookVolumeLabel(item.book);
|
||||
return (
|
||||
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}>
|
||||
<span className="continue-cover" aria-hidden="true">
|
||||
{item.book.coverPath ? <img src={api.bookCoverUrl(item.book.id)} alt="" /> : <BookOpen size={22} />}
|
||||
</span>
|
||||
<span className="continue-copy">
|
||||
<strong>{bookDisplayTitle(item.book)}</strong>
|
||||
<span>{item.book.author ?? "Auteur inconnu"}</span>
|
||||
{(volumeLabel || publishedDate) && <span>{[volumeLabel, publishedDate].filter(Boolean).join(" · ")}</span>}
|
||||
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(item.book)}</span>
|
||||
<Meter value={item.progress.percent} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
|
||||
@ -79,7 +93,7 @@ export function HomePage() {
|
||||
|
||||
<section className="book-grid span-3">
|
||||
{state.books.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BookDto, LibraryDto } from "@readabook/shared";
|
||||
import type { BookDto, JobDto, LibraryDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { hasActiveCoverWork, isBookCoverUpdating } from "../api/types";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
export function LibraryPage({ libraryId }: { libraryId: number }) {
|
||||
const [books, setBooks] = useState<BookDto[] | null>(null);
|
||||
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
|
||||
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
Promise.all([api.books({ libraryId }), api.libraries()]).then(([nextBooks, nextLibraries]) => {
|
||||
Promise.all([api.books({ libraryId }), api.libraries(), api.jobs().catch(() => [])]).then(([nextBooks, nextLibraries, nextJobs]) => {
|
||||
if (!alive) return;
|
||||
setBooks(nextBooks);
|
||||
setLibraries(nextLibraries);
|
||||
setJobs(nextJobs);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
@ -22,6 +25,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) {
|
||||
|
||||
if (!books) return <LoadingState />;
|
||||
const library = libraries.find((item) => item.id === libraryId);
|
||||
const fallbackCoverLoading = hasActiveCoverWork(jobs);
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
@ -37,7 +41,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) {
|
||||
{books.length ? (
|
||||
<section className="book-grid span-3">
|
||||
{books.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
|
||||
@ -3,7 +3,7 @@ import type { BookDto } from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { CbzReader } from "../reader/CbzReader";
|
||||
import { EpubReader } from "../reader/EpubReader";
|
||||
import { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
||||
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
||||
import { PdfReader } from "../reader/PdfReader";
|
||||
import { ReaderShell, type ReaderControls } from "../reader/ReaderShell";
|
||||
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
||||
@ -24,7 +24,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const { progress, saving, error: progressError, save } = useReaderProgress(bookId);
|
||||
const { progress, saving, error: progressError, save, queueSave } = useReaderProgress(bookId);
|
||||
const { preferences, setMode, error: preferencesError } = useReaderPreferences(bookId);
|
||||
|
||||
async function loadBook() {
|
||||
@ -58,20 +58,26 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
|
||||
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
|
||||
const savePdfPage = useCallback(
|
||||
(nextPage: number, pages: number) => {
|
||||
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
||||
setPage(nextPage);
|
||||
void save(`pdf:page:${nextPage}`, pdfPagePercent(nextPage, pages));
|
||||
const locator = pageLocator("pdf", nextPage, anchor);
|
||||
const percent = pdfPagePercent(nextPage, pages, anchor);
|
||||
if (strategy === "queued") queueSave(locator, percent);
|
||||
else void save(locator, percent);
|
||||
},
|
||||
[save]
|
||||
[queueSave, save]
|
||||
);
|
||||
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
|
||||
const saveComicPage = useCallback(
|
||||
(nextPage: number, pages: number) => {
|
||||
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
||||
setPage(nextPage);
|
||||
const prefix = book?.format === "cbr" ? "cbr" : "cbz";
|
||||
void save(`${prefix}:page:${nextPage}`, pdfPagePercent(nextPage, pages));
|
||||
const locator = pageLocator(prefix, nextPage, anchor);
|
||||
const percent = pdfPagePercent(nextPage, pages, anchor);
|
||||
if (strategy === "queued") queueSave(locator, percent);
|
||||
else void save(locator, percent);
|
||||
},
|
||||
[book?.format, save]
|
||||
[book?.format, queueSave, save]
|
||||
);
|
||||
|
||||
const readerError = error ?? progressError ?? preferencesError;
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import type { BookDto, JobDto } from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { hasActiveCoverWork, isBookCoverUpdating } from "../api/types";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
export function SearchPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [books, setBooks] = useState<BookDto[]>([]);
|
||||
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
@ -15,7 +17,9 @@ export function SearchPage() {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
setBooks(nextQuery.trim() ? await api.search(nextQuery.trim()) : await api.books());
|
||||
const [nextBooks, nextJobs] = await Promise.all([nextQuery.trim() ? api.search(nextQuery.trim()) : api.books(), api.jobs().catch(() => [])]);
|
||||
setBooks(nextBooks);
|
||||
setJobs(nextJobs);
|
||||
} catch (loadError) {
|
||||
const fallback = getApiFallback<BookDto[]>(loadError);
|
||||
setBooks(fallback ?? []);
|
||||
@ -25,6 +29,8 @@ export function SearchPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackCoverLoading = hasActiveCoverWork(jobs);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBooks("");
|
||||
}, []);
|
||||
@ -58,7 +64,7 @@ export function SearchPage() {
|
||||
) : books.length ? (
|
||||
<section className="book-grid span-3">
|
||||
{books.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
|
||||
53
apps/web/src/pages/SeriesPage.tsx
Normal file
53
apps/web/src/pages/SeriesPage.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { bookSeriesInfo } from "../book/metadata";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
export function SeriesPage({ seriesName }: { seriesName: string }) {
|
||||
const [books, setBooks] = useState<BookDto[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.books().then((nextBooks) => {
|
||||
if (alive) setBooks(nextBooks);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const seriesBooks = useMemo(() => {
|
||||
const expected = seriesName.trim().toLocaleLowerCase();
|
||||
return (books ?? [])
|
||||
.filter((book) => bookSeriesInfo(book)?.title.trim().toLocaleLowerCase() === expected)
|
||||
.sort((left, right) => (bookSeriesInfo(left)?.volumeNumber ?? Number.MAX_SAFE_INTEGER) - (bookSeriesInfo(right)?.volumeNumber ?? Number.MAX_SAFE_INTEGER));
|
||||
}, [books, seriesName]);
|
||||
|
||||
if (!books) return <LoadingState />;
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Panel className="span-3">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h1>{seriesName}</h1>
|
||||
<p>{seriesBooks.length} volumes reperes</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
{seriesBooks.length ? (
|
||||
<section className="book-grid span-3">
|
||||
{seriesBooks.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<Panel className="span-3">
|
||||
<EmptyState title="Serie introuvable" detail="Les volumes apparaitront ici quand le catalogue exposera leur serie." />
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,14 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MetadataSourcesConfigDto } from "@readabook/shared";
|
||||
import { metadataSourcesPayload, moveSource, normalizeMetadataSources, scheduleSummary } from "./adminAutomation";
|
||||
import type { AdminMetadataSourcesConfig } from "./adminAutomation";
|
||||
import {
|
||||
metadataSourcesPayload,
|
||||
moveSource,
|
||||
normalizeMetadataSources,
|
||||
providerLabels,
|
||||
providerUiMessage,
|
||||
providerUiStateLabel,
|
||||
scheduleSummary
|
||||
} from "./adminAutomation";
|
||||
|
||||
const config: MetadataSourcesConfigDto = {
|
||||
const config: AdminMetadataSourcesConfig = {
|
||||
isbnPriorityEnabled: true,
|
||||
sources: [
|
||||
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: true },
|
||||
{ provider: "local", enabled: false, priority: 99, hasApiKey: false },
|
||||
{ provider: "openlibrary", enabled: true, priority: 1, hasApiKey: false },
|
||||
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false }
|
||||
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false },
|
||||
{ provider: "comicvine", enabled: true, priority: 4, hasApiKey: false, requiresCredentials: true },
|
||||
{ provider: "mangadex", enabled: false, priority: 5, hasApiKey: false }
|
||||
]
|
||||
};
|
||||
|
||||
@ -27,17 +37,44 @@ describe("admin automation helpers", () => {
|
||||
sources: [
|
||||
{ provider: "openlibrary", enabled: true, priority: 1 },
|
||||
{ provider: "googlebooks", enabled: false, priority: 2 },
|
||||
{ provider: "bnf", enabled: false, priority: 3 }
|
||||
{ provider: "bnf", enabled: false, priority: 3 },
|
||||
{ provider: "comicvine", enabled: true, priority: 4 },
|
||||
{ provider: "mangadex", enabled: false, priority: 5 }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("moves only external providers", () => {
|
||||
const moved = moveSource(normalizeMetadataSources(config).sources, "bnf", -1);
|
||||
expect(moved.map((source) => source.provider)).toEqual(["local", "openlibrary", "bnf", "googlebooks"]);
|
||||
expect(moved.map((source) => source.provider)).toEqual(["local", "openlibrary", "bnf", "googlebooks", "comicvine", "mangadex"]);
|
||||
});
|
||||
|
||||
it("summarizes weekly schedules", () => {
|
||||
expect(scheduleSummary({ frequency: "weekly", time: "04:30", dayOfWeek: 1 }, "Scan")).toBe("Scan chaque lundi a 04:30.");
|
||||
});
|
||||
|
||||
it("adds Comic Vine and MangaDex when the backend omits them", () => {
|
||||
const normalized = normalizeMetadataSources({
|
||||
isbnPriorityEnabled: true,
|
||||
sources: [{ provider: "local", enabled: true, priority: 0, hasApiKey: false }]
|
||||
});
|
||||
expect(normalized.sources.map((source) => source.provider)).toContain("comicvine");
|
||||
expect(normalized.sources.map((source) => source.provider)).toContain("mangadex");
|
||||
expect(providerLabels.comicvine).toBe("Comic Vine");
|
||||
expect(providerLabels.mangadex).toBe("MangaDex");
|
||||
});
|
||||
|
||||
it("labels provider configuration, rate limit and error states", () => {
|
||||
expect(providerUiStateLabel({ provider: "comicvine", enabled: true, priority: 1, hasApiKey: false, requiresCredentials: true })).toBe(
|
||||
"A configurer"
|
||||
);
|
||||
expect(providerUiMessage({ provider: "comicvine", enabled: true, priority: 1, hasApiKey: false, requiresCredentials: true })).toBe(
|
||||
"Source activee, configuration incomplete."
|
||||
);
|
||||
expect(providerUiStateLabel({ provider: "mangadex", enabled: true, priority: 2, hasApiKey: false, rateLimited: true })).toBe("Limite");
|
||||
expect(providerUiMessage({ provider: "mangadex", enabled: true, priority: 2, hasApiKey: false, status: "quota_exceeded" })).toBe(
|
||||
"Quota ou limite temporaire atteint. ReadaBook reessaiera plus tard."
|
||||
);
|
||||
expect(providerUiStateLabel({ provider: "mangadex", enabled: true, priority: 2, hasApiKey: false, lastError: "500 stack" })).toBe("Erreur");
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,18 +6,57 @@ import type {
|
||||
UpdateMetadataSourcesConfigDto
|
||||
} from "@readabook/shared";
|
||||
|
||||
export const providerLabels: Record<MetadataProviderId, string> = {
|
||||
export type AdminMetadataProviderId = MetadataProviderId | "comicvine" | "mangadex";
|
||||
|
||||
export type AdminMetadataSourceConfig = Omit<MetadataSourceConfigDto, "provider"> & {
|
||||
provider: AdminMetadataProviderId;
|
||||
requiresCredentials?: boolean;
|
||||
status?: string | null;
|
||||
state?: string | null;
|
||||
health?: string | null;
|
||||
message?: string | null;
|
||||
lastError?: string | null;
|
||||
rateLimited?: boolean;
|
||||
quotaLimited?: boolean;
|
||||
};
|
||||
|
||||
export type AdminMetadataSourcesConfig = Omit<MetadataSourcesConfigDto, "sources"> & {
|
||||
sources: AdminMetadataSourceConfig[];
|
||||
};
|
||||
|
||||
export type ProviderUiState = "configured" | "missing-config" | "limited" | "error";
|
||||
|
||||
export const providerLabels: Record<AdminMetadataProviderId, string> = {
|
||||
local: "Fichier local",
|
||||
openlibrary: "OpenLibrary",
|
||||
googlebooks: "Google Books",
|
||||
bnf: "BnF"
|
||||
bnf: "BnF",
|
||||
comicvine: "Comic Vine",
|
||||
mangadex: "MangaDex"
|
||||
};
|
||||
|
||||
export const defaultMetadataSources: AdminMetadataSourceConfig[] = [
|
||||
{ provider: "local", enabled: true, priority: 0, hasApiKey: false },
|
||||
{ provider: "openlibrary", enabled: false, priority: 1, hasApiKey: false },
|
||||
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: false },
|
||||
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false },
|
||||
{ provider: "comicvine", enabled: false, priority: 4, hasApiKey: false, requiresCredentials: true },
|
||||
{ provider: "mangadex", enabled: false, priority: 5, hasApiKey: false }
|
||||
];
|
||||
|
||||
const weekdays = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"];
|
||||
|
||||
export function normalizeMetadataSources(config: MetadataSourcesConfigDto): MetadataSourcesConfigDto {
|
||||
const sorted = [...config.sources].sort((left, right) => left.priority - right.priority);
|
||||
const local = sorted.find((source) => source.provider === "local") ?? { provider: "local", enabled: true, priority: 0, hasApiKey: false };
|
||||
export function normalizeMetadataSources(config: MetadataSourcesConfigDto | AdminMetadataSourcesConfig): AdminMetadataSourcesConfig {
|
||||
const received = config.sources as AdminMetadataSourceConfig[];
|
||||
const merged = defaultMetadataSources.map((source) => ({
|
||||
...source,
|
||||
...received.find((item) => item.provider === source.provider)
|
||||
}));
|
||||
received.forEach((source) => {
|
||||
if (!merged.some((item) => item.provider === source.provider)) merged.push(source);
|
||||
});
|
||||
const sorted = merged.sort((left, right) => left.priority - right.priority);
|
||||
const local = sorted.find((source) => source.provider === "local") ?? defaultMetadataSources[0];
|
||||
const external = sorted.filter((source) => source.provider !== "local");
|
||||
return {
|
||||
isbnPriorityEnabled: config.isbnPriorityEnabled,
|
||||
@ -28,8 +67,8 @@ export function normalizeMetadataSources(config: MetadataSourcesConfigDto): Meta
|
||||
};
|
||||
}
|
||||
|
||||
export function metadataSourcesPayload(config: MetadataSourcesConfigDto): UpdateMetadataSourcesConfigDto {
|
||||
const sources: NonNullable<UpdateMetadataSourcesConfigDto["sources"]> = [];
|
||||
export function metadataSourcesPayload(config: AdminMetadataSourcesConfig): UpdateMetadataSourcesConfigDto {
|
||||
const sources: Array<{ provider: AdminMetadataProviderId; enabled: boolean; priority: number; apiKey?: string }> = [];
|
||||
config.sources.forEach((source) => {
|
||||
if (source.provider === "local") return;
|
||||
sources.push({
|
||||
@ -41,10 +80,10 @@ export function metadataSourcesPayload(config: MetadataSourcesConfigDto): Update
|
||||
return {
|
||||
isbnPriorityEnabled: config.isbnPriorityEnabled,
|
||||
sources
|
||||
};
|
||||
} as UpdateMetadataSourcesConfigDto;
|
||||
}
|
||||
|
||||
export function moveSource(sources: MetadataSourceConfigDto[], provider: MetadataProviderId, direction: -1 | 1): MetadataSourceConfigDto[] {
|
||||
export function moveSource(sources: AdminMetadataSourceConfig[], provider: AdminMetadataProviderId, direction: -1 | 1): AdminMetadataSourceConfig[] {
|
||||
const external = sources.filter((source) => source.provider !== "local");
|
||||
const index = external.findIndex((source) => source.provider === provider);
|
||||
const nextIndex = index + direction;
|
||||
@ -56,6 +95,34 @@ export function moveSource(sources: MetadataSourceConfigDto[], provider: Metadat
|
||||
return [local, ...nextExternal].map((source, priority) => ({ ...source, priority: source.provider === "local" ? 0 : priority }));
|
||||
}
|
||||
|
||||
function sourceStatusText(source: AdminMetadataSourceConfig): string {
|
||||
return [source.status, source.state, source.health].filter(Boolean).join(" ").toLowerCase();
|
||||
}
|
||||
|
||||
export function providerUiState(source: AdminMetadataSourceConfig): ProviderUiState {
|
||||
const status = sourceStatusText(source);
|
||||
if (source.rateLimited || source.quotaLimited || status.includes("limit") || status.includes("quota")) return "limited";
|
||||
if (source.lastError || status.includes("error") || status.includes("failed")) return "error";
|
||||
if (source.enabled && (source.requiresCredentials ?? false) && !source.hasApiKey) return "missing-config";
|
||||
return "configured";
|
||||
}
|
||||
|
||||
export function providerUiStateLabel(source: AdminMetadataSourceConfig): string {
|
||||
const state = providerUiState(source);
|
||||
if (state === "missing-config") return "A configurer";
|
||||
if (state === "limited") return "Limite";
|
||||
if (state === "error") return "Erreur";
|
||||
return "Configure";
|
||||
}
|
||||
|
||||
export function providerUiMessage(source: AdminMetadataSourceConfig): string {
|
||||
const state = providerUiState(source);
|
||||
if (state === "missing-config") return "Source activee, configuration incomplete.";
|
||||
if (state === "limited") return "Quota ou limite temporaire atteint. ReadaBook reessaiera plus tard.";
|
||||
if (state === "error") return "La derniere verification de cette source a echoue.";
|
||||
return source.enabled ? "Source prete." : "Source desactivee.";
|
||||
}
|
||||
|
||||
export function scheduleSummary(schedule: AutomationScheduleDto, subject: string): string {
|
||||
if (schedule.frequency === "disabled") return `${subject} desactive.`;
|
||||
if (schedule.frequency === "daily") return `${subject} tous les jours a ${schedule.time}.`;
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { CbzPagesDto, ReaderMode } from "../api/types";
|
||||
import type { ReaderControls } from "./ReaderShell";
|
||||
|
||||
type PageCommitStrategy = "immediate" | "queued";
|
||||
|
||||
function scrollContainerFor(element: HTMLElement | null): HTMLElement | null {
|
||||
return element?.closest(".reader-stage") as HTMLElement | null;
|
||||
}
|
||||
|
||||
export function CbzReader({
|
||||
bookId,
|
||||
page,
|
||||
@ -13,22 +19,27 @@ export function CbzReader({
|
||||
bookId: number;
|
||||
page: number;
|
||||
mode: ReaderMode;
|
||||
onPageCommit: (page: number, pages: number) => void;
|
||||
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
|
||||
onControlsChange: (controls: ReaderControls) => void;
|
||||
}) {
|
||||
const frameRef = useRef<HTMLDivElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
||||
const [error, setError] = useState<string>();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [visiblePage, setVisiblePage] = useState(page);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setError(undefined);
|
||||
setImageError(false);
|
||||
restoredRef.current = false;
|
||||
api
|
||||
.cbzPages(bookId)
|
||||
.then((nextPages) => {
|
||||
if (!alive) return;
|
||||
setPages(nextPages);
|
||||
if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount);
|
||||
if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount, 1, "immediate");
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setError("Archive CBZ indisponible.");
|
||||
@ -40,34 +51,103 @@ export function CbzReader({
|
||||
|
||||
const pageCount = pages?.pageCount ?? 1;
|
||||
const currentPage = Math.max(1, Math.min(page, pageCount));
|
||||
const displayedPage = mode === "vertical" ? Math.max(1, Math.min(visiblePage, pageCount)) : currentPage;
|
||||
const currentName = pages?.pages.find((item) => item.page === currentPage)?.name;
|
||||
|
||||
const go = useCallback((nextPage: number) => {
|
||||
setImageError(false);
|
||||
onPageCommit(Math.max(1, Math.min(nextPage, pageCount)), pageCount);
|
||||
}, [onPageCommit, pageCount]);
|
||||
const go = useCallback(
|
||||
(nextPage: number) => {
|
||||
const target = Math.max(1, Math.min(nextPage, pageCount));
|
||||
setImageError(false);
|
||||
if (mode === "vertical") {
|
||||
frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" });
|
||||
setVisiblePage(target);
|
||||
}
|
||||
onPageCommit(target, pageCount, mode === "vertical" ? 0 : 1, "immediate");
|
||||
},
|
||||
[mode, onPageCommit, pageCount]
|
||||
);
|
||||
|
||||
const goTop = useCallback(() => go(1), [go]);
|
||||
|
||||
useEffect(() => {
|
||||
onControlsChange({
|
||||
canPrevious: !error && !imageError && currentPage > 1,
|
||||
canNext: !error && !imageError && currentPage < pageCount,
|
||||
positionLabel: pages ? `${currentPage} / ${pageCount}` : "Ouverture archive",
|
||||
onPrevious: () => go(currentPage - 1),
|
||||
onNext: () => go(currentPage + 1)
|
||||
canPrevious: !error && !imageError && displayedPage > 1,
|
||||
canNext: !error && !imageError && displayedPage < pageCount,
|
||||
canTop: !error && !imageError && displayedPage > 1,
|
||||
positionLabel: pages ? `${displayedPage} / ${pageCount}` : "Ouverture archive",
|
||||
onPrevious: () => go(displayedPage - 1),
|
||||
onNext: () => go(displayedPage + 1),
|
||||
onTop: goTop
|
||||
});
|
||||
}, [currentPage, error, go, imageError, onControlsChange, pageCount, pages]);
|
||||
}, [displayedPage, error, go, goTop, imageError, onControlsChange, pageCount, pages]);
|
||||
|
||||
return (
|
||||
<div className={`cbz-reader cbz-reader-${mode}`}>
|
||||
{error || imageError ? (
|
||||
useEffect(() => {
|
||||
if (mode !== "vertical") return;
|
||||
const root = scrollContainerFor(frameRef.current);
|
||||
if (!root || !pages) return;
|
||||
const handleScroll = () => {
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const frames = Array.from(frameRef.current?.querySelectorAll<HTMLElement>("[data-reader-page]") ?? []);
|
||||
const active = frames
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const overlap = Math.min(rootRect.bottom, rect.bottom) - Math.max(rootRect.top, rect.top);
|
||||
const anchor = Math.max(0, Math.min(1, (rootRect.top - rect.top) / Math.max(1, rect.height)));
|
||||
return { page: Number(element.dataset.readerPage), overlap, anchor };
|
||||
})
|
||||
.filter((item) => Number.isInteger(item.page) && item.overlap > 0)
|
||||
.sort((left, right) => right.overlap - left.overlap)[0];
|
||||
if (!active) return;
|
||||
setVisiblePage(active.page);
|
||||
onPageCommit(active.page, pageCount, active.anchor, "queued");
|
||||
};
|
||||
root.addEventListener("scroll", handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
return () => root.removeEventListener("scroll", handleScroll);
|
||||
}, [mode, onPageCommit, pageCount, pages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "vertical" || !pages || restoredRef.current) return;
|
||||
restoredRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" });
|
||||
setVisiblePage(currentPage);
|
||||
});
|
||||
}, [currentPage, mode, pages]);
|
||||
|
||||
if (error || imageError) {
|
||||
return (
|
||||
<div className={`cbz-reader cbz-reader-${mode}`} ref={frameRef}>
|
||||
<div className="reader-fallback">
|
||||
<span>{error ?? "Page CBZ indisponible."}</span>
|
||||
<button className="ghost-button" onClick={() => go(currentPage)}>
|
||||
Reessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`cbz-reader cbz-reader-${mode}`} ref={frameRef}>
|
||||
{mode === "vertical" && pages ? (
|
||||
<div className="comic-strip">
|
||||
{pages.pages.map((item) => (
|
||||
<figure className="comic-page-frame" data-reader-page={item.page} key={item.page}>
|
||||
<img
|
||||
src={api.cbzPageUrl(bookId, item.page)}
|
||||
alt={item.name ?? `Page ${item.page}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<img src={api.cbzPageUrl(bookId, currentPage)} alt={currentName ?? `Page ${currentPage}`} onError={() => setImageError(true)} />
|
||||
<figure className="comic-page-frame" data-reader-page={currentPage}>
|
||||
<img src={api.cbzPageUrl(bookId, currentPage)} alt={currentName ?? `Page ${currentPage}`} onError={() => setImageError(true)} />
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,47 +1,177 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as pdfjs from "pdfjs-dist";
|
||||
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
||||
import { ReaderError } from "./ReaderError";
|
||||
import { pdfCanvasHasVisibleInk, pdfRenderScale } from "./pdfRender";
|
||||
import { configurePdfWorker } from "./pdfWorker";
|
||||
import type { ReaderControls } from "./ReaderShell";
|
||||
import type { ReaderMode } from "../api/types";
|
||||
|
||||
configurePdfWorker(pdfjs);
|
||||
|
||||
export function PdfReader({
|
||||
url,
|
||||
page,
|
||||
backHref,
|
||||
mode,
|
||||
onPageCommit,
|
||||
onControlsChange
|
||||
}: {
|
||||
type PageCommitStrategy = "immediate" | "queued";
|
||||
|
||||
type PdfReaderProps = {
|
||||
url: string;
|
||||
page: number;
|
||||
backHref: string;
|
||||
mode: ReaderMode;
|
||||
onPageCommit: (page: number, pages: number) => void;
|
||||
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
|
||||
onControlsChange: (controls: ReaderControls) => void;
|
||||
}) {
|
||||
};
|
||||
|
||||
type PdfPageCanvasProps = {
|
||||
document: pdfjs.PDFDocumentProxy;
|
||||
pageNumber: number;
|
||||
mode: ReaderMode;
|
||||
frameSize: { width: number; height: number };
|
||||
onError: (message: string) => void;
|
||||
};
|
||||
|
||||
let pdfLifecycle = Promise.resolve();
|
||||
|
||||
function enqueuePdfLifecycle<T>(work: () => Promise<T>) {
|
||||
const run = pdfLifecycle.then(work, work);
|
||||
pdfLifecycle = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
function isPdfTransitionError(error: unknown) {
|
||||
return error instanceof Error && (error.message.includes("worker is being destroyed") || error.message.includes("Rendering cancelled"));
|
||||
}
|
||||
|
||||
function pdfReaderErrorMessage(error: unknown) {
|
||||
if (isPdfTransitionError(error)) return "Le lecteur PDF termine une ouverture precedente.";
|
||||
return "Le PDF n'a pas pu s'ouvrir.";
|
||||
}
|
||||
|
||||
function scrollContainerFor(element: HTMLElement | null): HTMLElement | null {
|
||||
return element?.closest(".reader-stage") as HTMLElement | null;
|
||||
}
|
||||
|
||||
function PdfPageCanvas({ document, pageNumber, mode, frameSize, onError }: PdfPageCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(mode === "horizontal");
|
||||
const [rendered, setRendered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "horizontal") {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) setVisible(true);
|
||||
},
|
||||
{ root: scrollContainerFor(wrapper), rootMargin: "900px 0px" }
|
||||
);
|
||||
observer.observe(wrapper);
|
||||
return () => observer.disconnect();
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
let cancelled = false;
|
||||
let renderTask: pdfjs.RenderTask | undefined;
|
||||
setRendered(false);
|
||||
|
||||
async function renderPage() {
|
||||
try {
|
||||
const pdfPage = await document.getPage(pageNumber);
|
||||
if (cancelled) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const baseViewport = pdfPage.getViewport({ scale: 1 });
|
||||
const fitScale = pdfRenderScale(mode, frameSize, { width: baseViewport.width, height: baseViewport.height });
|
||||
const pixelRatio = Math.min(2, window.devicePixelRatio || 1);
|
||||
const renderScale = Math.max(0.25, Math.min(3, fitScale)) * pixelRatio;
|
||||
const viewport = pdfPage.getViewport({ scale: renderScale });
|
||||
const cssWidth = Math.floor(viewport.width / pixelRatio);
|
||||
const cssHeight = Math.floor(viewport.height / pixelRatio);
|
||||
canvas.width = Math.max(1, Math.floor(viewport.width));
|
||||
canvas.height = Math.max(1, Math.floor(viewport.height));
|
||||
canvas.style.width = `${cssWidth}px`;
|
||||
canvas.style.height = `${cssHeight}px`;
|
||||
const context = canvas.getContext("2d", { alpha: false });
|
||||
if (!context) return;
|
||||
context.fillStyle = "#f7f0df";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
|
||||
await renderTask.promise;
|
||||
if (!pdfCanvasHasVisibleInk(context.getImageData(0, 0, canvas.width, canvas.height))) {
|
||||
throw new Error(`La page PDF ${pageNumber} est blanche apres rendu.`);
|
||||
}
|
||||
if (!cancelled) setRendered(true);
|
||||
} catch (error) {
|
||||
if (!cancelled && !isPdfTransitionError(error)) onError(pdfReaderErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
void enqueuePdfLifecycle(renderPage);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
renderTask?.cancel();
|
||||
};
|
||||
}, [document, frameSize.height, frameSize.width, mode, onError, pageNumber, visible]);
|
||||
|
||||
return (
|
||||
<div className="pdf-page-frame" data-reader-page={pageNumber} ref={wrapperRef}>
|
||||
{!rendered && (
|
||||
<div className="reader-fallback">
|
||||
<span>Page {pageNumber}</span>
|
||||
</div>
|
||||
)}
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PdfReader({ url, page, backHref, mode, onPageCommit, onControlsChange }: PdfReaderProps) {
|
||||
const frameRef = useRef<HTMLDivElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [visiblePage, setVisiblePage] = useState(page);
|
||||
const [error, setError] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [frameSize, setFrameSize] = useState({ width: 980, height: 900 });
|
||||
|
||||
const currentPage = Math.max(1, Math.min(page, pages));
|
||||
const go = useCallback((nextPage: number) => onPageCommit(Math.max(1, Math.min(nextPage, pages)), pages), [onPageCommit, pages]);
|
||||
const displayedPage = mode === "vertical" ? Math.max(1, Math.min(visiblePage, pages)) : currentPage;
|
||||
|
||||
const go = useCallback(
|
||||
(nextPage: number) => {
|
||||
const target = Math.max(1, Math.min(nextPage, pages));
|
||||
if (mode === "vertical") {
|
||||
frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" });
|
||||
onPageCommit(target, pages, 0, "immediate");
|
||||
setVisiblePage(target);
|
||||
} else {
|
||||
onPageCommit(target, pages, 1, "immediate");
|
||||
}
|
||||
},
|
||||
[mode, onPageCommit, pages]
|
||||
);
|
||||
|
||||
const goTop = useCallback(() => go(1), [go]);
|
||||
const handlePageError = useCallback((message: string) => {
|
||||
setError(message);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onControlsChange({
|
||||
canPrevious: !loading && !error && currentPage > 1,
|
||||
canNext: !loading && !error && currentPage < pages,
|
||||
positionLabel: loading ? "Ouverture PDF" : `${currentPage} / ${pages}`,
|
||||
onPrevious: () => go(currentPage - 1),
|
||||
onNext: () => go(currentPage + 1)
|
||||
canPrevious: !loading && !error && displayedPage > 1,
|
||||
canNext: !loading && !error && displayedPage < pages,
|
||||
canTop: !loading && !error && displayedPage > 1,
|
||||
positionLabel: loading ? "Ouverture PDF" : `${displayedPage} / ${pages}`,
|
||||
onPrevious: () => go(displayedPage - 1),
|
||||
onNext: () => go(displayedPage + 1),
|
||||
onTop: goTop
|
||||
});
|
||||
}, [currentPage, error, go, loading, onControlsChange, pages]);
|
||||
}, [displayedPage, error, go, goTop, loading, onControlsChange, pages]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current;
|
||||
@ -59,70 +189,95 @@ export function PdfReader({
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
|
||||
let renderTask: pdfjs.RenderTask | undefined;
|
||||
async function render() {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
setDocumentProxy(null);
|
||||
restoredRef.current = false;
|
||||
|
||||
async function loadDocument() {
|
||||
try {
|
||||
configurePdfWorker(pdfjs);
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
||||
const document = await loadingTask.promise;
|
||||
if (cancelled) return;
|
||||
setPages(document.numPages);
|
||||
const pdfPage = await document.getPage(Math.max(1, Math.min(page, document.numPages)));
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const baseViewport = pdfPage.getViewport({ scale: 1 });
|
||||
const fitScale =
|
||||
mode === "vertical"
|
||||
? frameSize.width / baseViewport.width
|
||||
: Math.min(frameSize.width / baseViewport.width, frameSize.height / baseViewport.height);
|
||||
const renderScale = Math.max(0.35, Math.min(3, fitScale)) * Math.min(2, window.devicePixelRatio || 1);
|
||||
const viewport = pdfPage.getViewport({ scale: renderScale });
|
||||
canvas.width = Math.floor(viewport.width);
|
||||
canvas.height = Math.floor(viewport.height);
|
||||
canvas.style.width = `${Math.floor(viewport.width / Math.min(2, window.devicePixelRatio || 1))}px`;
|
||||
canvas.style.height = `${Math.floor(viewport.height / Math.min(2, window.devicePixelRatio || 1))}px`;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
|
||||
await renderTask.promise;
|
||||
if (!cancelled) setLoading(false);
|
||||
} catch (renderError) {
|
||||
const loadedDocument = await loadingTask.promise;
|
||||
if (cancelled) {
|
||||
await loadingTask.destroy();
|
||||
return;
|
||||
}
|
||||
setPages(loadedDocument.numPages);
|
||||
setDocumentProxy(loadedDocument);
|
||||
setLoading(false);
|
||||
} catch (loadError) {
|
||||
if (cancelled && isPdfTransitionError(loadError)) return;
|
||||
if (!cancelled) {
|
||||
setError(readerErrorMessage(renderError, "PDF indisponible"));
|
||||
setError(pdfReaderErrorMessage(loadError));
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
void render();
|
||||
|
||||
void enqueuePdfLifecycle(loadDocument);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
renderTask?.cancel();
|
||||
void loadingTask?.destroy();
|
||||
};
|
||||
}, [url, page, attempt, frameSize.height, frameSize.width, mode]);
|
||||
}, [attempt, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "vertical") return;
|
||||
const root = scrollContainerFor(frameRef.current);
|
||||
if (!root || !documentProxy) return;
|
||||
const handleScroll = () => {
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const frames = Array.from(frameRef.current?.querySelectorAll<HTMLElement>("[data-reader-page]") ?? []);
|
||||
const active = frames
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const overlap = Math.min(rootRect.bottom, rect.bottom) - Math.max(rootRect.top, rect.top);
|
||||
const anchor = Math.max(0, Math.min(1, (rootRect.top - rect.top) / Math.max(1, rect.height)));
|
||||
return { page: Number(element.dataset.readerPage), overlap, anchor };
|
||||
})
|
||||
.filter((item) => Number.isInteger(item.page) && item.overlap > 0)
|
||||
.sort((left, right) => right.overlap - left.overlap)[0];
|
||||
if (!active) return;
|
||||
setVisiblePage(active.page);
|
||||
onPageCommit(active.page, pages, active.anchor, "queued");
|
||||
};
|
||||
root.addEventListener("scroll", handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
return () => root.removeEventListener("scroll", handleScroll);
|
||||
}, [documentProxy, mode, onPageCommit, pages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "vertical" || !documentProxy || restoredRef.current) return;
|
||||
restoredRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" });
|
||||
setVisiblePage(currentPage);
|
||||
});
|
||||
}, [currentPage, documentProxy, mode]);
|
||||
|
||||
return (
|
||||
<div className="pdf-reader" ref={frameRef}>
|
||||
<div className={`pdf-reader pdf-reader-${mode}`} ref={frameRef}>
|
||||
{error ? (
|
||||
<ReaderError
|
||||
title="Lecture PDF indisponible"
|
||||
detail="ReadaBook n'a pas pu ouvrir ce fichier dans le lecteur web."
|
||||
technicalDetail={error}
|
||||
detail={error}
|
||||
downloadUrl={url}
|
||||
backHref={backHref}
|
||||
onRetry={() => setAttempt((value) => value + 1)}
|
||||
/>
|
||||
) : loading || !documentProxy ? (
|
||||
<div className="reader-fallback">
|
||||
<span>Ouverture PDF</span>
|
||||
</div>
|
||||
) : mode === "vertical" ? (
|
||||
<div className="pdf-strip">
|
||||
{Array.from({ length: pages }, (_, index) => (
|
||||
<PdfPageCanvas key={index + 1} document={documentProxy} pageNumber={index + 1} mode={mode} frameSize={frameSize} onError={handlePageError} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{loading && (
|
||||
<div className="reader-fallback">
|
||||
<span>Ouverture PDF</span>
|
||||
</div>
|
||||
)}
|
||||
<canvas ref={canvasRef} />
|
||||
</>
|
||||
<PdfPageCanvas document={documentProxy} pageNumber={currentPage} mode={mode} frameSize={frameSize} onError={handlePageError} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ArrowLeft, ArrowRight, Columns2, RotateCcw, Rows3, Save } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowLeft, ArrowRight, ArrowUp, Columns2, Maximize2, Minimize2, RotateCcw, Rows3, Save } from "lucide-react";
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { ErrorRibbon, Meter } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
import type { ReaderMode } from "../api/types";
|
||||
@ -7,9 +7,11 @@ import type { ReaderMode } from "../api/types";
|
||||
export type ReaderControls = {
|
||||
canPrevious: boolean;
|
||||
canNext: boolean;
|
||||
canTop?: boolean;
|
||||
positionLabel: string;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onTop?: () => void;
|
||||
};
|
||||
|
||||
type ReaderShellProps = {
|
||||
@ -41,6 +43,20 @@ export function ReaderShell({
|
||||
onToggleControls,
|
||||
children
|
||||
}: ReaderShellProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const root = document.querySelector(".reader-page");
|
||||
if (!document.fullscreenElement) void root?.requestFullscreen();
|
||||
else void document.exitFullscreen();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const updateFullscreen = () => setIsFullscreen(Boolean(document.fullscreenElement));
|
||||
updateFullscreen();
|
||||
document.addEventListener("fullscreenchange", updateFullscreen);
|
||||
return () => document.removeEventListener("fullscreenchange", updateFullscreen);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`reader-page reader-mode-${mode} ${controlsVisible ? "reader-controls-visible" : "reader-controls-hidden"}`}>
|
||||
<header className="reader-topbar" onClick={(event) => event.stopPropagation()}>
|
||||
@ -54,20 +70,25 @@ export function ReaderShell({
|
||||
</div>
|
||||
<div className="reader-toolbar">
|
||||
<button
|
||||
className={`ghost-button icon-only ${mode === "horizontal" ? "active" : ""}`}
|
||||
className={`ghost-button reader-mode-button ${mode === "horizontal" ? "active" : ""}`}
|
||||
onClick={() => onModeChange("horizontal")}
|
||||
aria-label="Lecture horizontale"
|
||||
title="Lecture horizontale"
|
||||
>
|
||||
<Columns2 size={18} />
|
||||
Horizontal
|
||||
</button>
|
||||
<button
|
||||
className={`ghost-button icon-only ${mode === "vertical" ? "active" : ""}`}
|
||||
className={`ghost-button reader-mode-button ${mode === "vertical" ? "active" : ""}`}
|
||||
onClick={() => onModeChange("vertical")}
|
||||
aria-label="Lecture verticale"
|
||||
title="Lecture verticale"
|
||||
>
|
||||
<Rows3 size={18} />
|
||||
Vertical
|
||||
</button>
|
||||
<button className="ghost-button icon-only" onClick={toggleFullscreen} aria-label={isFullscreen ? "Quitter le plein ecran" : "Plein ecran"}>
|
||||
{isFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||||
</button>
|
||||
{error && onRetry ? (
|
||||
<button className="ghost-button icon-only" onClick={onRetry} aria-label="Reessayer">
|
||||
@ -130,15 +151,29 @@ export function ReaderShell({
|
||||
<div className="reader-content">{children}</div>
|
||||
</div>
|
||||
<footer className="reader-stepper" onClick={(event) => event.stopPropagation()}>
|
||||
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
|
||||
<ArrowLeft size={16} />
|
||||
Precedent
|
||||
</button>
|
||||
{mode === "vertical" ? (
|
||||
<button className="ghost-button" onClick={controls.onTop} disabled={!controls.canTop}>
|
||||
<ArrowUp size={16} />
|
||||
Haut
|
||||
</button>
|
||||
) : (
|
||||
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
|
||||
<ArrowLeft size={16} />
|
||||
Precedent
|
||||
</button>
|
||||
)}
|
||||
<span>{controls.positionLabel}</span>
|
||||
<button className="ghost-button" onClick={controls.onNext} disabled={!controls.canNext}>
|
||||
Suivant
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
{mode === "vertical" ? (
|
||||
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
|
||||
<ArrowLeft size={16} />
|
||||
Page precedente
|
||||
</button>
|
||||
) : (
|
||||
<button className="ghost-button" onClick={controls.onNext} disabled={!controls.canNext}>
|
||||
Suivant
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "./locators";
|
||||
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePageAnchor, parsePdfPageLocator, pdfPagePercent } from "./locators";
|
||||
|
||||
describe("reader locators", () => {
|
||||
it("parses valid PDF page locators", () => {
|
||||
expect(parsePdfPageLocator("pdf:page:12")).toBe(12);
|
||||
expect(parsePdfPageLocator("pdf:page:12@0.250")).toBe(12);
|
||||
});
|
||||
|
||||
it("ignores invalid PDF locators", () => {
|
||||
@ -23,7 +24,15 @@ describe("reader locators", () => {
|
||||
|
||||
it("bounds PDF page percentages", () => {
|
||||
expect(pdfPagePercent(2, 4)).toBe(50);
|
||||
expect(pdfPagePercent(2, 4, 0.25)).toBe(31);
|
||||
expect(pdfPagePercent(8, 4)).toBe(100);
|
||||
expect(pdfPagePercent(1, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("serializes page locators with relative anchors", () => {
|
||||
expect(pageLocator("pdf", 3)).toBe("pdf:page:3");
|
||||
expect(pageLocator("cbz", 3, 0.25)).toBe("cbz:page:3@0.250");
|
||||
expect(parsePageAnchor("cbr:page:8@0.750")).toBe(0.75);
|
||||
expect(parsePageAnchor("cbr:page:8@2")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -14,11 +14,26 @@ export function parseCbrPageLocator(locator?: string | null): number | null {
|
||||
}
|
||||
|
||||
function parsePageSuffix(locator: string): number | null {
|
||||
const value = Number(locator.split(":").at(-1));
|
||||
const value = Number(locator.split(":").at(-1)?.split("@")[0]);
|
||||
return Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function pdfPagePercent(page: number, pages: number): number {
|
||||
if (!Number.isFinite(page) || !Number.isFinite(pages) || pages < 1) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round((page / pages) * 100)));
|
||||
export function parsePageAnchor(locator?: string | null): number | null {
|
||||
const rawAnchor = locator?.split("@")[1];
|
||||
if (!rawAnchor) return null;
|
||||
const anchor = Number(rawAnchor);
|
||||
return Number.isFinite(anchor) ? Math.max(0, Math.min(1, anchor)) : null;
|
||||
}
|
||||
|
||||
export function pageLocator(prefix: "pdf" | "cbz" | "cbr", page: number, anchor = 1): string {
|
||||
const normalizedPage = Number.isInteger(page) && page > 0 ? page : 1;
|
||||
const normalizedAnchor = Math.max(0, Math.min(1, anchor));
|
||||
if (normalizedAnchor >= 0.995) return `${prefix}:page:${normalizedPage}`;
|
||||
return `${prefix}:page:${normalizedPage}@${normalizedAnchor.toFixed(3)}`;
|
||||
}
|
||||
|
||||
export function pdfPagePercent(page: number, pages: number, anchor = 1): number {
|
||||
if (!Number.isFinite(page) || !Number.isFinite(pages) || pages < 1) return 0;
|
||||
const normalizedAnchor = Math.max(0, Math.min(1, anchor));
|
||||
return Math.max(0, Math.min(100, Math.round(((page - 1 + normalizedAnchor) / pages) * 100)));
|
||||
}
|
||||
|
||||
34
apps/web/src/reader/pdfRender.ts
Normal file
34
apps/web/src/reader/pdfRender.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { ReaderMode } from "../api/types";
|
||||
|
||||
const PDF_RENDER_BACKGROUND = { red: 247, green: 240, blue: 223 };
|
||||
const PDF_BLANK_THRESHOLD = 245;
|
||||
const PDF_BACKGROUND_TOLERANCE = 4;
|
||||
|
||||
export function pdfRenderScale(mode: ReaderMode, frameSize: { width: number; height: number }, pageSize: { width: number; height: number }) {
|
||||
if (pageSize.width <= 0 || pageSize.height <= 0) return 1;
|
||||
const fitWidth = frameSize.width / pageSize.width;
|
||||
if (mode === "vertical") return Math.min(1.65, fitWidth);
|
||||
return Math.min(fitWidth, frameSize.height / pageSize.height);
|
||||
}
|
||||
|
||||
function isNearRenderBackground(red: number, green: number, blue: number) {
|
||||
return (
|
||||
Math.abs(red - PDF_RENDER_BACKGROUND.red) <= PDF_BACKGROUND_TOLERANCE &&
|
||||
Math.abs(green - PDF_RENDER_BACKGROUND.green) <= PDF_BACKGROUND_TOLERANCE &&
|
||||
Math.abs(blue - PDF_RENDER_BACKGROUND.blue) <= PDF_BACKGROUND_TOLERANCE
|
||||
);
|
||||
}
|
||||
|
||||
export function pdfCanvasHasVisibleInk(imageData: Pick<ImageData, "data">) {
|
||||
const data = imageData.data;
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
const red = data[index] ?? 255;
|
||||
const green = data[index + 1] ?? 255;
|
||||
const blue = data[index + 2] ?? 255;
|
||||
const alpha = data[index + 3] ?? 255;
|
||||
if (alpha === 0) continue;
|
||||
const nearWhite = red >= PDF_BLANK_THRESHOLD && green >= PDF_BLANK_THRESHOLD && blue >= PDF_BLANK_THRESHOLD;
|
||||
if (!nearWhite && !isNearRenderBackground(red, green, blue)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { epubFileName } from "./EpubReader";
|
||||
import { pdfCanvasHasVisibleInk, pdfRenderScale } from "./pdfRender";
|
||||
import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
|
||||
import { readerErrorMessage } from "./ReaderError";
|
||||
|
||||
@ -30,4 +31,14 @@ describe("reader runtime helpers", () => {
|
||||
expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed");
|
||||
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
|
||||
});
|
||||
|
||||
it("separates PDF fit-page and fit-width scales", () => {
|
||||
expect(pdfRenderScale("horizontal", { width: 800, height: 600 }, { width: 400, height: 800 })).toBe(0.75);
|
||||
expect(pdfRenderScale("vertical", { width: 800, height: 600 }, { width: 400, height: 800 })).toBe(1.65);
|
||||
});
|
||||
|
||||
it("rejects blank PDF canvas renders", () => {
|
||||
expect(pdfCanvasHasVisibleInk({ data: new Uint8ClampedArray([255, 255, 255, 255, 247, 240, 223, 255]) })).toBe(false);
|
||||
expect(pdfCanvasHasVisibleInk({ data: new Uint8ClampedArray([255, 255, 255, 255, 32, 28, 24, 255]) })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ProgressDto } from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
|
||||
type PendingProgress = {
|
||||
locator: string;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
export function useReaderProgress(bookId: number) {
|
||||
const [progress, setProgress] = useState<ProgressDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const pendingRef = useRef<PendingProgress | null>(null);
|
||||
const saveTimerRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@ -24,6 +31,8 @@ export function useReaderProgress(bookId: number) {
|
||||
|
||||
const save = useCallback(
|
||||
async (locator: string, percent: number) => {
|
||||
pendingRef.current = null;
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
@ -44,5 +53,34 @@ export function useReaderProgress(bookId: number) {
|
||||
[bookId]
|
||||
);
|
||||
|
||||
return { progress, saving, error, save };
|
||||
const flush = useCallback(() => {
|
||||
const pending = pendingRef.current;
|
||||
if (!pending) return;
|
||||
void save(pending.locator, pending.percent);
|
||||
}, [save]);
|
||||
|
||||
const queueSave = useCallback(
|
||||
(locator: string, percent: number) => {
|
||||
pendingRef.current = { locator, percent };
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = window.setTimeout(flush, 2000);
|
||||
},
|
||||
[flush]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const flushOnHidden = () => {
|
||||
if (document.visibilityState === "hidden") flush();
|
||||
};
|
||||
window.addEventListener("beforeunload", flush);
|
||||
document.addEventListener("visibilitychange", flushOnHidden);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", flush);
|
||||
document.removeEventListener("visibilitychange", flushOnHidden);
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
flush();
|
||||
};
|
||||
}, [flush]);
|
||||
|
||||
return { progress, saving, error, save, queueSave, flush };
|
||||
}
|
||||
|
||||
9
apps/web/src/router.test.ts
Normal file
9
apps/web/src/router.test.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseRoute } from "./router";
|
||||
|
||||
describe("parseRoute", () => {
|
||||
it("keeps the frontend series view away from the backend /series endpoint", () => {
|
||||
expect(parseRoute("/series")).toEqual({ name: "home" });
|
||||
expect(parseRoute("/catalog/series/Daredevil")).toEqual({ name: "catalogSeries", seriesName: "Daredevil" });
|
||||
});
|
||||
});
|
||||
@ -3,6 +3,7 @@ export type Route =
|
||||
| { name: "setup"; step: string }
|
||||
| { name: "home" }
|
||||
| { name: "library"; libraryId: number }
|
||||
| { name: "catalogSeries"; seriesName: string }
|
||||
| { name: "book"; bookId: number }
|
||||
| { name: "reader"; bookId: number }
|
||||
| { name: "search" }
|
||||
@ -14,6 +15,7 @@ export function parseRoute(pathname = window.location.pathname): Route {
|
||||
if (parts[0] === "login") return { name: "login" };
|
||||
if (parts[0] === "setup") return { name: "setup", step: parts[1] ?? "admin" };
|
||||
if (parts[0] === "library") return { name: "library", libraryId: Number(parts[1] ?? 0) };
|
||||
if (parts[0] === "catalog" && parts[1] === "series") return { name: "catalogSeries", seriesName: decodeURIComponent(parts[2] ?? "") };
|
||||
if (parts[0] === "book") return { name: "book", bookId: Number(parts[1] ?? 0) };
|
||||
if (parts[0] === "reader") return { name: "reader", bookId: Number(parts[1] ?? 0) };
|
||||
if (parts[0] === "search") return { name: "search" };
|
||||
|
||||
@ -173,6 +173,7 @@ h2 {
|
||||
|
||||
.cover-button,
|
||||
.book-portrait {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
@ -187,6 +188,19 @@ h2 {
|
||||
color: var(--brass);
|
||||
}
|
||||
|
||||
.cover-loading {
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
bottom: 7px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid rgba(247, 240, 223, 0.78);
|
||||
border-top-color: var(--brass);
|
||||
border-radius: 999px;
|
||||
background: rgba(23, 17, 13, 0.62);
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.cover-button img,
|
||||
.book-portrait img {
|
||||
width: 100%;
|
||||
@ -223,6 +237,10 @@ h2 {
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.book-card-submeta {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.book-card-meta,
|
||||
.book-card-actions,
|
||||
.section-heading,
|
||||
@ -232,6 +250,12 @@ h2 {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.book-card-meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.book-card-actions,
|
||||
.section-heading {
|
||||
justify-content: space-between;
|
||||
@ -251,6 +275,7 @@ h2 {
|
||||
}
|
||||
|
||||
.format-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 7px;
|
||||
border-radius: 999px;
|
||||
color: #17110d;
|
||||
@ -259,6 +284,27 @@ h2 {
|
||||
background: var(--brass);
|
||||
}
|
||||
|
||||
.volume-pill {
|
||||
flex: 0 0 auto;
|
||||
max-width: 48px;
|
||||
overflow: hidden;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid rgba(213, 168, 77, 0.56);
|
||||
border-radius: 999px;
|
||||
color: #f5dfaa;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background: rgba(213, 168, 77, 0.12);
|
||||
}
|
||||
|
||||
.book-card-language {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.format-pdf {
|
||||
background: var(--lacquer);
|
||||
color: var(--ink);
|
||||
@ -274,6 +320,35 @@ h2 {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.metadata-pill {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.metadata-enriched {
|
||||
border-color: rgba(45, 111, 99, 0.72);
|
||||
color: #d8fff5;
|
||||
background: rgba(45, 111, 99, 0.18);
|
||||
}
|
||||
|
||||
.metadata-partial {
|
||||
border-color: rgba(213, 168, 77, 0.68);
|
||||
color: #f5dfaa;
|
||||
background: rgba(213, 168, 77, 0.12);
|
||||
}
|
||||
|
||||
.metadata-missing {
|
||||
border-color: rgba(169, 72, 52, 0.62);
|
||||
color: #f2b8aa;
|
||||
background: rgba(169, 72, 52, 0.12);
|
||||
}
|
||||
|
||||
.continue-grid,
|
||||
.library-list,
|
||||
.job-list {
|
||||
@ -281,9 +356,15 @@ h2 {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.job-list {
|
||||
max-height: 350px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.continue-tile,
|
||||
.library-list button,
|
||||
.job-list div,
|
||||
.job-list > div,
|
||||
.library-table > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
@ -300,6 +381,62 @@ h2 {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.job-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.job-list time {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.job-copy small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.continue-tile {
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.continue-cover {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(213, 168, 77, 0.35);
|
||||
border-radius: 5px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(213, 168, 77, 0.2), rgba(45, 111, 99, 0.22)),
|
||||
var(--paper-soft);
|
||||
color: var(--brass);
|
||||
}
|
||||
|
||||
.continue-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.continue-copy {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.continue-copy > span {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.continue-copy .metadata-pill {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.library-copy {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
@ -523,7 +660,7 @@ select {
|
||||
|
||||
.provider-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 1fr) minmax(190px, 280px) auto;
|
||||
grid-template-columns: minmax(210px, 1fr) minmax(240px, 360px) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
@ -541,6 +678,51 @@ select {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.provider-config {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-state-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-state-line small {
|
||||
flex: 1 1 160px;
|
||||
}
|
||||
|
||||
.provider-warning {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(213, 168, 77, 0.45);
|
||||
border-radius: 6px;
|
||||
color: #f5dfaa;
|
||||
background: rgba(213, 168, 77, 0.1);
|
||||
}
|
||||
|
||||
.provider-state-configured {
|
||||
border-color: rgba(45, 111, 99, 0.72);
|
||||
color: #d8fff5;
|
||||
background: rgba(45, 111, 99, 0.18);
|
||||
}
|
||||
|
||||
.provider-state-missing-config {
|
||||
border-color: rgba(213, 168, 77, 0.68);
|
||||
color: #f5dfaa;
|
||||
background: rgba(213, 168, 77, 0.12);
|
||||
}
|
||||
|
||||
.provider-state-limited,
|
||||
.provider-state-error {
|
||||
border-color: rgba(169, 72, 52, 0.62);
|
||||
color: #f2b8aa;
|
||||
background: rgba(169, 72, 52, 0.12);
|
||||
}
|
||||
|
||||
.provider-actions,
|
||||
.save-bar,
|
||||
.save-bar div {
|
||||
@ -635,6 +817,33 @@ select {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.book-fact-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 6px 0 14px;
|
||||
}
|
||||
|
||||
.book-fact-list > div {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.book-fact-list dt {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.book-fact-list dd {
|
||||
margin: 4px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.lead {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
@ -656,6 +865,11 @@ select {
|
||||
background: #120e0b;
|
||||
}
|
||||
|
||||
.reader-page:fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.reader-topbar {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
@ -687,6 +901,12 @@ select {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reader-toolbar .reader-mode-button {
|
||||
gap: 7px;
|
||||
padding-inline: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reader-toolbar .active {
|
||||
border-color: rgba(213, 168, 77, 0.72);
|
||||
color: var(--brass);
|
||||
@ -730,6 +950,12 @@ select {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pdf-reader-vertical,
|
||||
.cbz-reader-vertical {
|
||||
place-items: start center;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.epub-host {
|
||||
display: grid;
|
||||
width: min(100%, 980px);
|
||||
@ -746,18 +972,34 @@ select {
|
||||
color: #17110d;
|
||||
}
|
||||
|
||||
.pdf-reader canvas,
|
||||
.cbz-reader img {
|
||||
.pdf-page-frame,
|
||||
.comic-page-frame {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 64px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pdf-page-frame canvas {
|
||||
display: block;
|
||||
max-width: min(100%, 980px);
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #f7f0df;
|
||||
}
|
||||
|
||||
.cbz-reader img {
|
||||
width: auto;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
max-width: min(100%, 980px);
|
||||
max-height: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #f7f0df;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@ -767,17 +1009,28 @@ select {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.reader-mode-vertical .pdf-reader canvas,
|
||||
.pdf-strip,
|
||||
.comic-strip {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.reader-mode-vertical .pdf-page-frame canvas,
|
||||
.reader-mode-vertical .cbz-reader img {
|
||||
width: min(100%, 980px);
|
||||
height: auto;
|
||||
max-height: none;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.reader-mode-horizontal .pdf-reader canvas,
|
||||
.reader-mode-horizontal .pdf-page-frame canvas,
|
||||
.reader-mode-horizontal .cbz-reader img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@ -966,6 +1219,7 @@ select {
|
||||
|
||||
.library-table > div,
|
||||
.search-form,
|
||||
.book-fact-list,
|
||||
.automation-grid,
|
||||
.provider-row,
|
||||
.provider-local,
|
||||
|
||||
@ -9,6 +9,7 @@ export default defineConfig({
|
||||
"/auth": "http://127.0.0.1:3000",
|
||||
"/admin": "http://127.0.0.1:3000",
|
||||
"/books": "http://127.0.0.1:3000",
|
||||
"/series": "http://127.0.0.1:3000",
|
||||
"/progress": "http://127.0.0.1:3000",
|
||||
"/healthz": "http://127.0.0.1:3000"
|
||||
}
|
||||
|
||||
@ -84,9 +84,21 @@ export const UpdateLibrarySchema = z.object({
|
||||
});
|
||||
export type UpdateLibraryDto = z.infer<typeof UpdateLibrarySchema>;
|
||||
|
||||
export const SeriesSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
title: z.string(),
|
||||
normalizedTitle: z.string(),
|
||||
description: z.string().nullable(),
|
||||
publisher: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string()
|
||||
});
|
||||
export type SeriesDto = z.infer<typeof SeriesSchema>;
|
||||
|
||||
export const BookSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
libraryId: z.number().int().positive(),
|
||||
seriesId: z.number().int().positive().nullable().optional(),
|
||||
title: z.string(),
|
||||
author: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
@ -95,9 +107,16 @@ export const BookSchema = z.object({
|
||||
language: z.string().nullable(),
|
||||
publisher: z.string().nullable(),
|
||||
publishedDate: z.string().nullable(),
|
||||
volumeNumber: z.number().int().nullable().optional(),
|
||||
volumeLabel: z.string().nullable().optional(),
|
||||
format: z.enum(["epub", "pdf", "cbz", "cbr"]),
|
||||
filePath: z.string(),
|
||||
coverPath: z.string().nullable(),
|
||||
metadataStatus: z.enum(["enriched", "partial", "none"]).default("none"),
|
||||
metadataProvenance: z.record(z.string(), z.string()).default({}),
|
||||
series: SeriesSchema.nullable().optional(),
|
||||
scanStatus: z.enum(["idle", "running", "succeeded", "failed"]).default("idle"),
|
||||
enrichmentStatus: z.enum(["idle", "running", "succeeded", "failed"]).default("idle"),
|
||||
fileSize: z.number().int().nonnegative(),
|
||||
fileMtime: z.string(),
|
||||
createdAt: z.string(),
|
||||
@ -152,7 +171,7 @@ export const JobSchema = z.object({
|
||||
});
|
||||
export type JobDto = z.infer<typeof JobSchema>;
|
||||
|
||||
export const MetadataProviderIdSchema = z.enum(["local", "openlibrary", "googlebooks", "bnf"]);
|
||||
export const MetadataProviderIdSchema = z.enum(["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"]);
|
||||
export type MetadataProviderId = z.infer<typeof MetadataProviderIdSchema>;
|
||||
|
||||
export const MetadataSourceConfigSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user