From 93bb40a8cd674c3b3f1be45a26373635eeb3cd95 Mon Sep 17 00:00:00 2001 From: Git Agent Date: Mon, 24 Aug 2026 09:11:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(api,web):=20mod=C3=A9lisation=20s=C3=A9rie?= =?UTF-8?q?=20+=20volume=20et=20parcours=20de=20s=C3=A9rie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Table series + colonnes seriesId/volumeNumber/volumeLabel sur les livres, extraction souple des numéros (T03, 003, etc.) via extract-series-volume - Contrôleur et page de série, badge volume sur les cartes de livre, styles associés (fond/clarté série, ajustements globaux) - Statuts de scan/enrichissement et provenance des métadonnées en base Co-Authored-By: Claude Opus 4.8 --- apps/api/src/books/books.module.ts | 3 +- apps/api/src/books/books.service.test.ts | 180 ++++++++++++ apps/api/src/books/books.service.ts | 133 ++++++--- apps/api/src/books/series.controller.ts | 19 ++ .../api/src/database/database.service.test.ts | 127 ++++++++- apps/api/src/database/database.service.ts | 150 +++++++++- apps/api/src/database/schema.ts | 23 +- .../metadata/extract-series-volume.test.ts | 43 +++ .../use-cases/extract-series-volume.ts | 67 +++++ apps/web/src/App.tsx | 3 + apps/web/src/api/mockData.ts | 35 ++- apps/web/src/api/types.ts | 14 + apps/web/src/book/metadata.test.ts | 131 +++++++++ apps/web/src/book/metadata.ts | 171 +++++++++++ apps/web/src/components/BookCard.tsx | 19 +- apps/web/src/pages/BookPage.tsx | 30 +- apps/web/src/pages/HomePage.tsx | 34 ++- apps/web/src/pages/LibraryPage.tsx | 10 +- apps/web/src/pages/SearchPage.tsx | 12 +- apps/web/src/pages/SeriesPage.tsx | 53 ++++ apps/web/src/router.test.ts | 9 + apps/web/src/router.ts | 2 + apps/web/src/styles/app.css | 268 +++++++++++++++++- packages/shared/src/index.ts | 21 +- 24 files changed, 1481 insertions(+), 76 deletions(-) create mode 100644 apps/api/src/books/books.service.test.ts create mode 100644 apps/api/src/books/series.controller.ts create mode 100644 apps/api/src/metadata/extract-series-volume.test.ts create mode 100644 apps/api/src/metadata/use-cases/extract-series-volume.ts create mode 100644 apps/web/src/book/metadata.test.ts create mode 100644 apps/web/src/book/metadata.ts create mode 100644 apps/web/src/pages/SeriesPage.tsx create mode 100644 apps/web/src/router.test.ts 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/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/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/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/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/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/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/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({