diff --git a/apps/api/src/books/books.module.ts b/apps/api/src/books/books.module.ts index c183835..6471b20 100644 --- a/apps/api/src/books/books.module.ts +++ b/apps/api/src/books/books.module.ts @@ -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] }) diff --git a/apps/api/src/books/books.service.test.ts b/apps/api/src/books/books.service.test.ts new file mode 100644 index 0000000..f17f9ba --- /dev/null +++ b/apps/api/src/books/books.service.test.ts @@ -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; + } +} diff --git a/apps/api/src/books/books.service.ts b/apps/api/src/books/books.service.ts index 43fd7da..dd83c48 100644 --- a/apps/api/src/books/books.service.ts +++ b/apps/api/src/books/books.service.ts @@ -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>).map(mapBookRow); + return (rows as Array>).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`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) { + 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) { - 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 { + 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 {}; + } +} diff --git a/apps/api/src/books/series.controller.ts b/apps/api/src/books/series.controller.ts new file mode 100644 index 0000000..7eb3e9a --- /dev/null +++ b/apps/api/src/books/series.controller.ts @@ -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)); + } +} diff --git a/apps/api/src/common/cbr.ts b/apps/api/src/common/cbr.ts index cbeacad..76f44d2 100644 --- a/apps/api/src/common/cbr.ts +++ b/apps/api/src/common/cbr.ts @@ -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 { diff --git a/apps/api/src/database/database.service.test.ts b/apps/api/src/database/database.service.test.ts index 0f0c6cb..d2303bd 100644 --- a/apps/api/src/database/database.service.test.ts +++ b/apps/api/src/database/database.service.test.ts @@ -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 { diff --git a/apps/api/src/database/database.service.ts b/apps/api/src/database/database.service.ts index 3cd07a9..ce3d873 100644 --- a/apps/api/src/database/database.service.ts +++ b/apps/api/src/database/database.service.ts @@ -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( diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts index 8951e18..5783122 100644 --- a/apps/api/src/database/schema.ts +++ b/apps/api/src/database/schema.ts @@ -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"), diff --git a/apps/api/src/metadata/adapters/bnf.provider.ts b/apps/api/src/metadata/adapters/bnf.provider.ts index 575d20d..bf35efa 100644 --- a/apps/api/src/metadata/adapters/bnf.provider.ts +++ b/apps/api/src/metadata/adapters/bnf.provider.ts @@ -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 | 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))); diff --git a/apps/api/src/metadata/adapters/comic-vine.provider.ts b/apps/api/src/metadata/adapters/comic-vine.provider.ts new file mode 100644 index 0000000..bbbce6f --- /dev/null +++ b/apps/api/src/metadata/adapters/comic-vine.provider.ts @@ -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 { + 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 { + 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 { + 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> }; + 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 { + 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): 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[] { + 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(); + 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 { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +function stringValue(value: unknown): string | null { + if (typeof value === "number") return String(value); + return typeof value === "string" && value.trim() ? value.trim() : null; +} diff --git a/apps/api/src/metadata/adapters/google-books.provider.ts b/apps/api/src/metadata/adapters/google-books.provider.ts index 793a1d8..1fa6d74 100644 --- a/apps/api/src/metadata/adapters/google-books.provider.ts +++ b/apps/api/src/metadata/adapters/google-books.provider.ts @@ -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 }> }; - 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 { + const attempts = googleBookQueries(query); + const matches: MetadataMatch[] = []; + const seen = new Set(); + 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 { 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 }> }; + const response = await providerFetch(this.id, url, { timeoutMs: 4000 }); + const data = (await parseGoogleResponse(response)) as { items?: Array<{ id?: string; volumeInfo?: Record }> }; return (data.items ?? []) - .map((item) => item.volumeInfo) - .filter((info): info is Record => Boolean(info)) - .map((info) => ({ + .map((item) => ({ sourceId: item.id, info: item.volumeInfo })) + .filter((item): item is { sourceId: string | undefined; info: Record } => 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 { + 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[] { + 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): 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; + return ( + stringValue(links.extraLarge) ?? + stringValue(links.large) ?? + stringValue(links.medium) ?? + stringValue(links.thumbnail) ?? + stringValue(links.smallThumbnail) + )?.replace(/^http:/, "https:") ?? null; +} diff --git a/apps/api/src/metadata/adapters/local.provider.ts b/apps/api/src/metadata/adapters/local.provider.ts index 64061cf..8ac1762 100644 --- a/apps/api/src/metadata/adapters/local.provider.ts +++ b/apps/api/src/metadata/adapters/local.provider.ts @@ -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) } ]; } diff --git a/apps/api/src/metadata/adapters/mangadex.provider.ts b/apps/api/src/metadata/adapters/mangadex.provider.ts new file mode 100644 index 0000000..e8b3b2c --- /dev/null +++ b/apps/api/src/metadata/adapters/mangadex.provider.ts @@ -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 { + 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 { + const titles = mangaDexTitleQueries(query.title); + const matches: Array = []; + const seen = new Map(); + 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 { + 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> }; + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function parseMangaDexResponse(response: Response): Promise { + 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): MetadataMatch & { mangaDexRank?: number } { + const id = stringValue(manga.id); + const attributes = objectValue(manga.attributes); + const relationships = Array.isArray(manga.relationships) ? (manga.relationships as Array>) : []; + 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[] { + 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; + return stringValue(entries.en) ?? stringValue(entries.fr) ?? Object.values(entries).map(stringValue).find(Boolean) ?? null; +} + +function relationshipNames(relationships: Array>): 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 { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +function stringValue(value: unknown): string | null { + if (typeof value === "number") return String(value); + return typeof value === "string" && value.trim() ? value.trim() : null; +} diff --git a/apps/api/src/metadata/adapters/open-library.provider.ts b/apps/api/src/metadata/adapters/open-library.provider.ts index 892421f..2683b16 100644 --- a/apps/api/src/metadata/adapters/open-library.provider.ts +++ b/apps/api/src/metadata/adapters/open-library.provider.ts @@ -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> }; 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 { 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, expectedIsbn13); } private async lookupIsbn(isbn: string, expectedIsbn13: string | null): Promise { - 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, 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 { 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; 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 | null { + const covers = Array.isArray(edition.covers) ? edition.covers : []; + return openLibraryCoverUrl(covers[0], stringValue(edition.key)?.split("/").pop() ?? null); +} diff --git a/apps/api/src/metadata/adapters/provider-fetch.ts b/apps/api/src/metadata/adapters/provider-fetch.ts new file mode 100644 index 0000000..f26c5fa --- /dev/null +++ b/apps/api/src/metadata/adapters/provider-fetch.ts @@ -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 { + 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 { + 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); +} diff --git a/apps/api/src/metadata/extract-series-volume.test.ts b/apps/api/src/metadata/extract-series-volume.test.ts new file mode 100644 index 0000000..5c845d5 --- /dev/null +++ b/apps/api/src/metadata/extract-series-volume.test.ts @@ -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"); + }); +}); diff --git a/apps/api/src/metadata/local-metadata-hints.test.ts b/apps/api/src/metadata/local-metadata-hints.test.ts index 19f9e2b..53bab86 100644 --- a/apps/api/src/metadata/local-metadata-hints.test.ts +++ b/apps/api/src/metadata/local-metadata-hints.test.ts @@ -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); }); }); diff --git a/apps/api/src/metadata/metadata-providers.test.ts b/apps/api/src/metadata/metadata-providers.test.ts index da905c1..df6cc28 100644 --- a/apps/api/src/metadata/metadata-providers.test.ts +++ b/apps/api/src/metadata/metadata-providers.test.ts @@ -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); 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); + }); + + 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: "

Origin story & family secrets.

", + 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 () => { diff --git a/apps/api/src/metadata/metadata.module.ts b/apps/api/src/metadata/metadata.module.ts index d15002a..5f7145b 100644 --- a/apps/api/src/metadata/metadata.module.ts +++ b/apps/api/src/metadata/metadata.module.ts @@ -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 {} diff --git a/apps/api/src/metadata/metadata.service.test.ts b/apps/api/src/metadata/metadata.service.test.ts index 0e1adea..9a7823d 100644 --- a/apps/api/src/metadata/metadata.service.test.ts +++ b/apps/api/src/metadata/metadata.service.test.ts @@ -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>(async () => null); + const bnfSearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise>(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>(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 { diff --git a/apps/api/src/metadata/metadata.service.ts b/apps/api/src/metadata/metadata.service.ts index 1553ba0..005ca2a 100644 --- a/apps/api/src/metadata/metadata.service.ts +++ b/apps/api/src/metadata/metadata.service.ts @@ -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 { + ): 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 { 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 { + 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 { 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 { + 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 { + 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 { + 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): 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; + 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); +} diff --git a/apps/api/src/metadata/metadata.types.ts b/apps/api/src/metadata/metadata.types.ts index c23b836..f2200da 100644 --- a/apps/api/src/metadata/metadata.types.ts +++ b/apps/api/src/metadata/metadata.types.ts @@ -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 & { sourceId?: string | null; + coverUrl?: string | null; identifiers?: Partial; + scoreTitle?: string | null; }; +export type MetadataField = "title" | "author" | "description" | "isbn" | "language" | "publisher" | "publishedDate" | "coverPath"; + +export type MetadataStatus = "enriched" | "partial" | "none"; + +export type MetadataProvenance = Partial>; + export type MetadataProviderConfig = { provider: MetadataProviderId; enabled: boolean; diff --git a/apps/api/src/metadata/normalize-published-date.test.ts b/apps/api/src/metadata/normalize-published-date.test.ts new file mode 100644 index 0000000..5bd62f0 --- /dev/null +++ b/apps/api/src/metadata/normalize-published-date.test.ts @@ -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(); + }); +}); diff --git a/apps/api/src/metadata/use-cases/extract-series-volume.ts b/apps/api/src/metadata/use-cases/extract-series-volume.ts new file mode 100644 index 0000000..d4f35cc --- /dev/null +++ b/apps/api/src/metadata/use-cases/extract-series-volume.ts @@ -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(); +} diff --git a/apps/api/src/metadata/use-cases/normalize-published-date.ts b/apps/api/src/metadata/use-cases/normalize-published-date.ts new file mode 100644 index 0000000..9c7fe72 --- /dev/null +++ b/apps/api/src/metadata/use-cases/normalize-published-date.ts @@ -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; +} diff --git a/apps/api/src/metadata/use-cases/score-metadata-match.ts b/apps/api/src/metadata/use-cases/score-metadata-match.ts index a81de00..84e16b3 100644 --- a/apps/api/src/metadata/use-cases/score-metadata-match.ts +++ b/apps/api/src/metadata/use-cases/score-metadata-match.ts @@ -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 { + return new Set( + value + .split(/[,;&/]|\band\b|\bet\b/gi) + .map(normalizeText) + .filter(Boolean) + .sort() + ); +} + +function tokens(value: string): Set { + return new Set(value.split(" ").filter(Boolean)); +} + +function jaccard(leftTokens: Set, rightTokens: Set): 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; +} diff --git a/apps/api/src/scanner/metadata.test.ts b/apps/api/src/scanner/metadata.test.ts index 18e0b9a..c7ca2b4 100644 --- a/apps/api/src/scanner/metadata.test.ts +++ b/apps/api/src/scanner/metadata.test.ts @@ -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$/); + }); +}); diff --git a/apps/api/src/scanner/metadata.ts b/apps/api/src/scanner/metadata.ts index ea232f1..b5bd46a 100644 --- a/apps/api/src/scanner/metadata.ts +++ b/apps/api/src/scanner/metadata.ts @@ -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 { - 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; } diff --git a/apps/api/src/scanner/scanner.service.test.ts b/apps/api/src/scanner/scanner.service.test.ts index 2238eee..b23b2b8 100644 --- a/apps/api/src/scanner/scanner.service.test.ts +++ b/apps/api/src/scanner/scanner.service.test.ts @@ -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 = { + 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, 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; + } +} diff --git a/apps/api/src/scanner/scanner.service.ts b/apps/api/src/scanner/scanner.service.ts index b526caa..33382b6 100644 --- a/apps/api/src/scanner/scanner.service.ts +++ b/apps/api/src/scanner/scanner.service.ts @@ -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 { 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, + 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): 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>(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, 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 { + if (!value) return {}; + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +function isUniqueFilePathError(error: unknown): boolean { + return error instanceof Error && error.message.includes("UNIQUE constraint failed: books.file_path"); +} diff --git a/apps/web/nginx/default.conf b/apps/web/nginx/default.conf index 4061245..8b917b9 100644 --- a/apps/web/nginx/default.conf +++ b/apps/web/nginx/default.conf @@ -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; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0831671..a5e4683 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 ) : route.name === "library" ? ( + ) : route.name === "catalogSeries" ? ( + ) : route.name === "book" ? ( ) : route.name === "reader" ? ( diff --git a/apps/web/src/api/mockData.ts b/apps/web/src/api/mockData.ts index 23acb66..569c6f6 100644 --- a/apps/web/src/api/mockData.ts +++ b/apps/web/src/api/mockData.ts @@ -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 & { + metadataStatus?: BookMetadataStatus; + metadataProvenance?: Record; + 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[] = [ diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 28536d3..af8a676 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -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; +} diff --git a/apps/web/src/book/metadata.test.ts b/apps/web/src/book/metadata.test.ts new file mode 100644 index 0000000..ff0a08e --- /dev/null +++ b/apps/web/src/book/metadata.test.ts @@ -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; + 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 }); + }); +}); diff --git a/apps/web/src/book/metadata.ts b/apps/web/src/book/metadata.ts new file mode 100644 index 0000000..31a6ce7 --- /dev/null +++ b/apps/web/src/book/metadata.ts @@ -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"; +} diff --git a/apps/web/src/components/BookCard.tsx b/apps/web/src/components/BookCard.tsx index 91b6476..a6725fc 100644 --- a/apps/web/src/components/BookCard.tsx +++ b/apps/web/src/components/BookCard.tsx @@ -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 (
- {book.language ?? "langue inconnue"} + {volumeLabel && {volumeLabel}} + {book.language ?? "langue inconnue"}
-

{book.title}

+

{bookDisplayTitle(book)}

{book.author ?? "Auteur inconnu"}

+ {(volumeLabel || publishedDate) && ( +

+ {[volumeLabel, publishedDate].filter(Boolean).join(" · ")} +

+ )} + {bookMetadataStateLabel(book)} {!compact &&

{book.description ?? "Notice absente du catalogue."}

}
+ {series && ( + + )} {error && ( - ))} + {state.continueReading.map((item) => { + const metadataState = bookMetadataState(item.book); + const publishedDate = displayPublishedDate(item.book.publishedDate); + const volumeLabel = bookVolumeLabel(item.book); + return ( + + ); + })}
) : ( @@ -79,7 +93,7 @@ export function HomePage() {
{state.books.map((book) => ( - + ))}
diff --git a/apps/web/src/pages/LibraryPage.tsx b/apps/web/src/pages/LibraryPage.tsx index c236922..2777027 100644 --- a/apps/web/src/pages/LibraryPage.tsx +++ b/apps/web/src/pages/LibraryPage.tsx @@ -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(null); const [libraries, setLibraries] = useState([]); + const [jobs, setJobs] = useState([]); 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 ; const library = libraries.find((item) => item.id === libraryId); + const fallbackCoverLoading = hasActiveCoverWork(jobs); return (
@@ -37,7 +41,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) { {books.length ? (
{books.map((book) => ( - + ))}
) : ( diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx index 4cd025d..64d7455 100644 --- a/apps/web/src/pages/ReaderPage.tsx +++ b/apps/web/src/pages/ReaderPage.tsx @@ -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(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; diff --git a/apps/web/src/pages/SearchPage.tsx b/apps/web/src/pages/SearchPage.tsx index 802f63d..9fab0e7 100644 --- a/apps/web/src/pages/SearchPage.tsx +++ b/apps/web/src/pages/SearchPage.tsx @@ -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([]); + const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(); @@ -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(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 ? (
{books.map((book) => ( - + ))}
) : ( diff --git a/apps/web/src/pages/SeriesPage.tsx b/apps/web/src/pages/SeriesPage.tsx new file mode 100644 index 0000000..05c3bc0 --- /dev/null +++ b/apps/web/src/pages/SeriesPage.tsx @@ -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(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 ; + + return ( +
+ +
+
+

{seriesName}

+

{seriesBooks.length} volumes reperes

+
+
+
+ {seriesBooks.length ? ( +
+ {seriesBooks.map((book) => ( + + ))} +
+ ) : ( + + + + )} +
+ ); +} diff --git a/apps/web/src/pages/adminAutomation.test.ts b/apps/web/src/pages/adminAutomation.test.ts index 46d961e..e8ae5c6 100644 --- a/apps/web/src/pages/adminAutomation.test.ts +++ b/apps/web/src/pages/adminAutomation.test.ts @@ -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"); + }); }); diff --git a/apps/web/src/pages/adminAutomation.ts b/apps/web/src/pages/adminAutomation.ts index ba31007..5931e94 100644 --- a/apps/web/src/pages/adminAutomation.ts +++ b/apps/web/src/pages/adminAutomation.ts @@ -6,18 +6,57 @@ import type { UpdateMetadataSourcesConfigDto } from "@readabook/shared"; -export const providerLabels: Record = { +export type AdminMetadataProviderId = MetadataProviderId | "comicvine" | "mangadex"; + +export type AdminMetadataSourceConfig = Omit & { + 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 & { + sources: AdminMetadataSourceConfig[]; +}; + +export type ProviderUiState = "configured" | "missing-config" | "limited" | "error"; + +export const providerLabels: Record = { 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 = []; +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}.`; diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx index 4117b7c..c845b6c 100644 --- a/apps/web/src/reader/CbzReader.tsx +++ b/apps/web/src/reader/CbzReader.tsx @@ -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(null); + const restoredRef = useRef(false); const [pages, setPages] = useState(null); const [error, setError] = useState(); 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 ( -
- {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("[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 ( +
{error ?? "Page CBZ indisponible."}
+
+ ); + } + + return ( +
+ {mode === "vertical" && pages ? ( +
+ {pages.pages.map((item) => ( +
+ {item.name setImageError(true)} + /> +
+ ))} +
) : ( - {currentName setImageError(true)} /> +
+ {currentName setImageError(true)} /> +
)}
); diff --git a/apps/web/src/reader/PdfReader.tsx b/apps/web/src/reader/PdfReader.tsx index 9cb4c70..06683a1 100644 --- a/apps/web/src/reader/PdfReader.tsx +++ b/apps/web/src/reader/PdfReader.tsx @@ -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(work: () => Promise) { + 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(null); + const wrapperRef = useRef(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 ( +
+ {!rendered && ( +
+ Page {pageNumber} +
+ )} + +
+ ); +} + +export function PdfReader({ url, page, backHref, mode, onPageCommit, onControlsChange }: PdfReaderProps) { const frameRef = useRef(null); + const restoredRef = useRef(false); + const [documentProxy, setDocumentProxy] = useState(null); const [pages, setPages] = useState(1); + const [visiblePage, setVisiblePage] = useState(page); const [error, setError] = useState(); 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("[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 ( -
+
{error ? ( setAttempt((value) => value + 1)} /> + ) : loading || !documentProxy ? ( +
+ Ouverture PDF +
+ ) : mode === "vertical" ? ( +
+ {Array.from({ length: pages }, (_, index) => ( + + ))} +
) : ( - <> - {loading && ( -
- Ouverture PDF -
- )} - - + )}
); diff --git a/apps/web/src/reader/ReaderShell.tsx b/apps/web/src/reader/ReaderShell.tsx index aff8de1..0141280 100644 --- a/apps/web/src/reader/ReaderShell.tsx +++ b/apps/web/src/reader/ReaderShell.tsx @@ -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 (
event.stopPropagation()}> @@ -54,20 +70,25 @@ export function ReaderShell({
+ {error && onRetry ? (
event.stopPropagation()}> - + {mode === "vertical" ? ( + + ) : ( + + )} {controls.positionLabel} - + {mode === "vertical" ? ( + + ) : ( + + )}
); diff --git a/apps/web/src/reader/locators.test.ts b/apps/web/src/reader/locators.test.ts index 77db26c..52c4562 100644 --- a/apps/web/src/reader/locators.test.ts +++ b/apps/web/src/reader/locators.test.ts @@ -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); + }); }); diff --git a/apps/web/src/reader/locators.ts b/apps/web/src/reader/locators.ts index a8c66d8..9b7ac00 100644 --- a/apps/web/src/reader/locators.ts +++ b/apps/web/src/reader/locators.ts @@ -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))); } diff --git a/apps/web/src/reader/pdfRender.ts b/apps/web/src/reader/pdfRender.ts new file mode 100644 index 0000000..0bead5a --- /dev/null +++ b/apps/web/src/reader/pdfRender.ts @@ -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) { + 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; +} diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts index 6ad5bc2..5d1ecf7 100644 --- a/apps/web/src/reader/readerRuntime.test.ts +++ b/apps/web/src/reader/readerRuntime.test.ts @@ -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); + }); }); diff --git a/apps/web/src/reader/useReaderProgress.ts b/apps/web/src/reader/useReaderProgress.ts index f9845fa..409f507 100644 --- a/apps/web/src/reader/useReaderProgress.ts +++ b/apps/web/src/reader/useReaderProgress.ts @@ -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(null); const [saving, setSaving] = useState(false); const [error, setError] = useState(); + const pendingRef = useRef(null); + const saveTimerRef = useRef(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 }; } diff --git a/apps/web/src/router.test.ts b/apps/web/src/router.test.ts new file mode 100644 index 0000000..b6927e0 --- /dev/null +++ b/apps/web/src/router.test.ts @@ -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" }); + }); +}); diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index ad59c67..d6e4c22 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -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" }; diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 743649d..e16d657 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -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, diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 4ece1d3..227b91a 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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" } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0151b80..12e4753 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -84,9 +84,21 @@ export const UpdateLibrarySchema = z.object({ }); export type UpdateLibraryDto = z.infer; +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; + 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; -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; export const MetadataSourceConfigSchema = z.object({