diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..64ab2ae --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Local compose defaults. Copy to .env when you need machine-specific paths. + +# Directory mounted read-only as /library in the API container. +READABOOK_LIBRARY_HOST_PATH=./data/library + +# Optional path accepted by the API and translated to /library. +# For QA with the real local corpus, set for example: +# READABOOK_LIBRARY_HOST_PATH=./Books +# READABOOK_LIBRARY_ALIAS_FROM=/absolute/path/to/ReadaBook/Books +READABOOK_LIBRARY_ALIAS_FROM=/library diff --git a/.gitignore b/.gitignore index 4216187..3e5b5d4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,8 @@ dist *.sqlite *.sqlite-* *.tsbuildinfo -data/storage +data/ +Books/ coverage .pnpm-store .ideai/ diff --git a/README.md b/README.md index bdfc20f..732362d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ ReadaBook est une application locale-first pour cataloguer, rechercher et lire une bibliothèque personnelle de livres EPUB/PDF stockés sur disque. Le MVP livré vise un usage domestique : un administrateur déclare un dossier local, lance un scan, puis les livres deviennent accessibles via un catalogue web et une API locale. +## Dépôt distant + +Le dépôt est hébergé sur un Gitea self-hosted et peut être cloné via : + +```bash +git clone https://gitea.anthonybouteiller.ovh/blomios/ReadaBook.git +``` + +Le déploiement git suit un git-flow simplifié : `main` (releases), `develop` (intégration), `feature/*` / `fix/*` (travail en cours). + ## Fonctionnalités MVP présentes - Backend NestJS/Fastify exécutable avec healthcheck `GET /healthz`. @@ -94,12 +104,14 @@ La PWA fournit `manifest.webmanifest`, `sw.js`, une icône maskable SVG et `disp Volumes : - `./data:/data` : base SQLite `/data/readabook.sqlite` et cache `/data/storage`. -- `./data/library:/library:ro` : bibliothèque locale scannée en lecture seule. +- `${READABOOK_LIBRARY_HOST_PATH:-./data/library}:/library:ro` : bibliothèque locale scannée en lecture seule. Variables principales : - `JWT_SECRET` : secret JWT, à changer hors développement. - `OPEN_LIBRARY_ENABLED=true|false` : active/désactive l’enrichissement distant. +- `READABOOK_LIBRARY_HOST_PATH` : dossier hôte monté en lecture seule sur `/library`. +- `READABOOK_LIBRARY_ALIAS_FROM` : chemin alternatif accepté par l’API et traduit vers `/library`. - `DATABASE_PATH=/data/readabook.sqlite` - `STORAGE_DIR=/data/storage` @@ -134,6 +146,16 @@ curl -b cookies.txt \ http://localhost:3000/admin/libraries ``` +Pour tester le corpus réel local `Books/` sans le versionner, crée un `.env` local : + +```bash +READABOOK_LIBRARY_HOST_PATH=./Books +READABOOK_LIBRARY_ALIAS_FROM=/chemin/absolu/vers/ReadaBook/Books +``` + +QA peut ensuite créer la bibliothèque avec le chemin absolu saisi dans `READABOOK_LIBRARY_ALIAS_FROM`; +l’API le traduit vers `/library`, puis le scan manuel teste l’extraction ISBN/métadonnées/jaquettes sur ce corpus. + ## Lancer un scan ```bash diff --git a/apps/api/package.json b/apps/api/package.json index 1e3a4d3..c0cb9ca 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,6 +26,7 @@ "fastify": "^5.2.1", "jose": "^5.9.6", "mime-types": "^2.1.35", + "node-unrar-js": "^2.0.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "zod": "^3.24.2" diff --git a/apps/api/src/admin/admin.controller.ts b/apps/api/src/admin/admin.controller.ts index 401d0d7..9ccad44 100644 --- a/apps/api/src/admin/admin.controller.ts +++ b/apps/api/src/admin/admin.controller.ts @@ -1,9 +1,13 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, UseGuards } from "@nestjs/common"; import { CreateLibraryDto, CreateLibrarySchema, CreateUserDto, CreateUserSchema, + UpdateAutomationSettingsDto, + UpdateAutomationSettingsSchema, + UpdateMetadataSourcesConfigDto, + UpdateMetadataSourcesConfigSchema, UpdateLibraryDto, UpdateLibrarySchema, UpdateUserDto, @@ -13,9 +17,11 @@ import { AuthGuard } from "../auth/auth.guard.js"; import { Roles } from "../auth/roles.decorator.js"; import { RolesGuard } from "../auth/roles.guard.js"; import { AuthService } from "../auth/auth.service.js"; +import { AutomationService } from "../automation/automation.service.js"; import { ZodValidationPipe } from "../common/zod-validation.pipe.js"; import { JobsService } from "../jobs/jobs.service.js"; import { LibrariesService } from "../libraries/libraries.service.js"; +import { MetadataService } from "../metadata/metadata.service.js"; import { ScannerService } from "../scanner/scanner.service.js"; @Controller("admin") @@ -26,7 +32,9 @@ export class AdminController { private readonly auth: AuthService, private readonly libraries: LibrariesService, private readonly jobs: JobsService, - private readonly scanner: ScannerService + private readonly scanner: ScannerService, + private readonly metadata: MetadataService, + private readonly automation: AutomationService ) {} @Get("users") @@ -80,4 +88,34 @@ export class AdminController { listJobs() { return this.jobs.list(); } + + @Get("metadata-sources") + metadataSources() { + return this.metadata.getSourcesConfig(); + } + + @Put("metadata-sources") + updateMetadataSources(@Body(new ZodValidationPipe(UpdateMetadataSourcesConfigSchema)) body: UpdateMetadataSourcesConfigDto) { + return this.metadata.updateSourcesConfig(body); + } + + @Get("automation") + automationSettings() { + return this.automation.getSettings(); + } + + @Put("automation") + updateAutomationSettings(@Body(new ZodValidationPipe(UpdateAutomationSettingsSchema)) body: UpdateAutomationSettingsDto) { + return this.automation.updateSettings(body); + } + + @Post("automation/run-scan") + runAutomationScan() { + return this.automation.runScanNow(); + } + + @Post("automation/run-enrich") + runAutomationEnrich() { + return this.automation.runEnrichNow(); + } } diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts index 71e6d2e..995f4fa 100644 --- a/apps/api/src/admin/admin.module.ts +++ b/apps/api/src/admin/admin.module.ts @@ -1,13 +1,15 @@ import { Module } from "@nestjs/common"; import { AuthModule } from "../auth/auth.module.js"; +import { AutomationModule } from "../automation/automation.module.js"; import { DatabaseModule } from "../database/database.module.js"; import { JobsModule } from "../jobs/jobs.module.js"; import { LibrariesService } from "../libraries/libraries.service.js"; +import { MetadataModule } from "../metadata/metadata.module.js"; import { ScannerModule } from "../scanner/scanner.module.js"; import { AdminController } from "./admin.controller.js"; @Module({ - imports: [AuthModule, DatabaseModule, JobsModule, ScannerModule], + imports: [AuthModule, DatabaseModule, JobsModule, ScannerModule, MetadataModule, AutomationModule], controllers: [AdminController], providers: [LibrariesService] }) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index df14680..e3e7c93 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,14 +1,16 @@ import { Module } from "@nestjs/common"; import { AdminModule } from "./admin/admin.module.js"; +import { AutomationModule } from "./automation/automation.module.js"; import { AuthModule } from "./auth/auth.module.js"; import { BooksModule } from "./books/books.module.js"; import { DatabaseModule } from "./database/database.module.js"; import { ProgressModule } from "./progress/progress.module.js"; +import { ReaderModule } from "./reader/reader.module.js"; import { ScannerModule } from "./scanner/scanner.module.js"; import { HealthController } from "./health.controller.js"; @Module({ - imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ScannerModule], + imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ScannerModule, AutomationModule], controllers: [HealthController] }) export class AppModule {} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 9e6c92f..a8da930 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,7 +1,14 @@ -import { Body, Controller, Get, Post, Res, UseGuards } from "@nestjs/common"; +import { Body, Controller, Get, Patch, Post, Res, UseGuards } from "@nestjs/common"; import "@fastify/cookie"; import { FastifyReply } from "fastify"; -import { BootstrapAdminDto, BootstrapAdminSchema, LoginDto, LoginSchema } from "@readabook/shared"; +import { + BootstrapAdminDto, + BootstrapAdminSchema, + LoginDto, + LoginSchema, + UpdateAccountDto, + UpdateAccountSchema +} from "@readabook/shared"; import { ZodValidationPipe } from "../common/zod-validation.pipe.js"; import { AuthGuard } from "./auth.guard.js"; import { AuthService } from "./auth.service.js"; @@ -11,6 +18,11 @@ import { CurrentUser, CurrentUserParam } from "./current-user.js"; export class AuthController { constructor(private readonly auth: AuthService) {} + @Get("status") + async status() { + return this.auth.status(); + } + @Post("bootstrap") async bootstrap(@Body(new ZodValidationPipe(BootstrapAdminSchema)) body: BootstrapAdminDto) { return this.auth.bootstrapAdmin(body); @@ -40,4 +52,13 @@ export class AuthController { me(@CurrentUserParam() user: CurrentUser) { return { user }; } + + @Patch("me") + @UseGuards(AuthGuard) + updateMe( + @CurrentUserParam() user: CurrentUser, + @Body(new ZodValidationPipe(UpdateAccountSchema)) body: UpdateAccountDto + ) { + return this.auth.updateOwnAccount(user.id, body); + } } diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 40174ee..d6a6f8d 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -1,14 +1,14 @@ -import { ConflictException, Injectable, UnauthorizedException } from "@nestjs/common"; +import { ConflictException, Injectable, OnModuleInit, UnauthorizedException } from "@nestjs/common"; import { eq } from "drizzle-orm"; import { SignJWT, jwtVerify } from "jose"; import argon2 from "argon2"; -import { BootstrapAdminDto, CreateUserDto, LoginDto, UpdateUserDto } from "@readabook/shared"; +import { BootstrapAdminDto, CreateUserDto, LoginDto, UpdateAccountDto, UpdateUserDto } from "@readabook/shared"; import { DatabaseService } from "../database/database.service.js"; import { users } from "../database/schema.js"; import { CurrentUser } from "./current-user.js"; @Injectable() -export class AuthService { +export class AuthService implements OnModuleInit { readonly cookieName: string; private readonly secret: Uint8Array; @@ -17,6 +17,10 @@ export class AuthService { this.secret = new TextEncoder().encode(database.config.jwtSecret); } + async onModuleInit(): Promise { + await this.ensureInitialAdmin(); + } + get cookieSecure(): boolean { return this.database.config.cookieSecure; } @@ -29,6 +33,26 @@ export class AuthService { return this.createUser({ ...input, role: "admin" }); } + async status() { + const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get(); + const initialAdmin = this.database.db + .select({ passwordHash: users.passwordHash, role: users.role }) + .from(users) + .where(eq(users.email, this.database.config.initialAdminEmail.toLowerCase())) + .get(); + const initialAdminPasswordIsDefault = + Boolean(initialAdmin) && + initialAdmin?.role === "admin" && + this.database.config.initialAdminPasswordIsDefault && + (await argon2.verify(initialAdmin.passwordHash, this.database.config.initialAdminPassword)); + + return { + hasUsers: Boolean(existing), + initialAdminEmail: this.database.config.initialAdminEmail.toLowerCase(), + initialAdminPasswordIsDefault + }; + } + async login(input: LoginDto): Promise<{ token: string; user: CurrentUser }> { const user = this.database.db.select().from(users).where(eq(users.email, input.email.toLowerCase())).get(); if (!user || !(await argon2.verify(user.passwordHash, input.password))) { @@ -65,6 +89,35 @@ export class AuthService { .all(); } + async updateOwnAccount(id: number, input: UpdateAccountDto) { + const user = this.database.db.select().from(users).where(eq(users.id, id)).get(); + if (!user || !(await argon2.verify(user.passwordHash, input.currentPassword))) { + throw new UnauthorizedException("Current password is invalid"); + } + + const values: Partial = { updatedAt: this.database.now() }; + if (input.email) values.email = input.email.toLowerCase(); + if (input.name !== undefined) values.name = input.name; + if (input.newPassword) values.passwordHash = await argon2.hash(input.newPassword); + + try { + return this.database.db + .update(users) + .set(values) + .where(eq(users.id, id)) + .returning({ + id: users.id, + email: users.email, + name: users.name, + role: users.role, + createdAt: users.createdAt + }) + .get(); + } catch { + throw new ConflictException("Email already exists"); + } + } + async createUser(input: CreateUserDto) { const now = this.database.now(); const passwordHash = await argon2.hash(input.password); @@ -125,4 +178,22 @@ export class AuthService { .setExpirationTime("7d") .sign(this.secret); } + + private async ensureInitialAdmin(): Promise { + const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get(); + if (existing) return; + + const now = this.database.now(); + this.database.db + .insert(users) + .values({ + email: this.database.config.initialAdminEmail.toLowerCase(), + name: "Initial administrator", + passwordHash: await argon2.hash(this.database.config.initialAdminPassword), + role: "admin", + createdAt: now, + updatedAt: now + }) + .run(); + } } diff --git a/apps/api/src/automation/automation.module.ts b/apps/api/src/automation/automation.module.ts new file mode 100644 index 0000000..0f48c8f --- /dev/null +++ b/apps/api/src/automation/automation.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { DatabaseModule } from "../database/database.module.js"; +import { ScannerModule } from "../scanner/scanner.module.js"; +import { AutomationService } from "./automation.service.js"; + +@Module({ + imports: [DatabaseModule, ScannerModule], + providers: [AutomationService], + exports: [AutomationService] +}) +export class AutomationModule {} diff --git a/apps/api/src/automation/automation.service.test.ts b/apps/api/src/automation/automation.service.test.ts new file mode 100644 index 0000000..f701068 --- /dev/null +++ b/apps/api/src/automation/automation.service.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { nextRunAt } from "./automation.service.js"; + +describe("automation scheduling", () => { + it("computes the next daily run", () => { + const next = nextRunAt({ frequency: "daily", time: "03:30", dayOfWeek: 1 }, new Date("2026-08-23T02:00:00.000Z")); + expect(next.toISOString()).toBe("2026-08-23T03:30:00.000Z"); + }); + + it("moves elapsed weekly runs to the next week", () => { + const next = nextRunAt({ frequency: "weekly", time: "03:30", dayOfWeek: 0 }, new Date("2026-08-23T04:00:00.000Z")); + expect(next.toISOString()).toBe("2026-08-30T03:30:00.000Z"); + }); +}); diff --git a/apps/api/src/automation/automation.service.ts b/apps/api/src/automation/automation.service.ts new file mode 100644 index 0000000..76c5b87 --- /dev/null +++ b/apps/api/src/automation/automation.service.ts @@ -0,0 +1,209 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import { Dirent, FSWatcher, readdirSync, watch } from "node:fs"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { AutomationScheduleDto, AutomationSettingsDto, UpdateAutomationSettingsDto } from "@readabook/shared"; +import { DatabaseService } from "../database/database.service.js"; +import { automationSettings, libraries } from "../database/schema.js"; +import { ScannerService } from "../scanner/scanner.service.js"; + +type WatchEntry = { + watchers: FSWatcher[]; + timer: NodeJS.Timeout | null; +}; + +const defaultSchedule: AutomationScheduleDto = { frequency: "disabled", time: "03:00", dayOfWeek: 1 }; + +@Injectable() +export class AutomationService implements OnModuleInit, OnModuleDestroy { + private readonly watchers = new Map(); + private scanTimer: NodeJS.Timeout | null = null; + private enrichTimer: NodeJS.Timeout | null = null; + + constructor( + private readonly database: DatabaseService, + private readonly scanner: ScannerService + ) {} + + onModuleInit(): void { + this.applyRuntimeSettings(); + } + + onModuleDestroy(): void { + this.stopWatchers(); + this.clearSchedules(); + } + + getSettings(): AutomationSettingsDto { + const row = this.readRow(); + return { + watchLibraries: row.watchLibraries, + autoEnrichNewBooks: row.autoEnrichNewBooks, + scanSchedule: parseSchedule(row.scanScheduleJson), + enrichSchedule: parseSchedule(row.enrichScheduleJson) + }; + } + + updateSettings(input: UpdateAutomationSettingsDto): AutomationSettingsDto { + const current = this.getSettings(); + const next: AutomationSettingsDto = { + watchLibraries: input.watchLibraries ?? current.watchLibraries, + autoEnrichNewBooks: input.autoEnrichNewBooks ?? current.autoEnrichNewBooks, + scanSchedule: input.scanSchedule ? normalizeSchedule(input.scanSchedule) : current.scanSchedule, + enrichSchedule: input.enrichSchedule ? normalizeSchedule(input.enrichSchedule) : current.enrichSchedule + }; + this.database.db + .update(automationSettings) + .set({ + watchLibraries: next.watchLibraries, + autoEnrichNewBooks: next.autoEnrichNewBooks, + scanScheduleJson: JSON.stringify(next.scanSchedule), + enrichScheduleJson: JSON.stringify(next.enrichSchedule), + updatedAt: this.database.now() + }) + .where(eq(automationSettings.id, 1)) + .run(); + this.applyRuntimeSettings(); + return this.getSettings(); + } + + runScanNow() { + return this.scanner.enqueueAllLibrariesScan("Manual automation scan"); + } + + runEnrichNow() { + return this.scanner.enqueueMetadataEnrichment("Manual metadata enrichment"); + } + + private applyRuntimeSettings(): void { + const settings = this.getSettings(); + settings.watchLibraries ? this.startWatchers() : this.stopWatchers(); + this.configureSchedules(settings); + } + + private startWatchers(): void { + const enabledLibraries = this.database.db.select().from(libraries).where(eq(libraries.enabled, true)).all(); + const enabledIds = new Set(enabledLibraries.map((library) => library.id)); + for (const [id, entry] of this.watchers) { + if (!enabledIds.has(id)) { + entry.watchers.forEach((watcher) => watcher.close()); + if (entry.timer) clearTimeout(entry.timer); + this.watchers.delete(id); + } + } + + for (const library of enabledLibraries) { + if (this.watchers.has(library.id)) continue; + const watchers = watchLibraryDirs(library.path, (_event, filename) => { + if (filename && !isBookPath(String(filename))) return; + const current = this.watchers.get(library.id); + if (!current) return; + if (current.timer) clearTimeout(current.timer); + current.timer = setTimeout(() => { + current.timer = null; + this.scanner.enqueueLibraryScan(library.id); + }, 1500); + }); + for (const watcher of watchers) { + watcher.on("error", () => { + this.watchers.delete(library.id); + }); + } + this.watchers.set(library.id, { watchers, timer: null }); + } + } + + private stopWatchers(): void { + for (const entry of this.watchers.values()) { + entry.watchers.forEach((watcher) => watcher.close()); + if (entry.timer) clearTimeout(entry.timer); + } + this.watchers.clear(); + } + + private configureSchedules(settings: AutomationSettingsDto): void { + this.clearSchedules(); + this.scanTimer = scheduleNext(settings.scanSchedule, () => { + this.scanner.enqueueAllLibrariesScan("Scheduled library scan"); + this.configureSchedules(this.getSettings()); + }); + this.enrichTimer = scheduleNext(settings.enrichSchedule, () => { + this.scanner.enqueueMetadataEnrichment("Scheduled metadata enrichment"); + this.configureSchedules(this.getSettings()); + }); + } + + private clearSchedules(): void { + if (this.scanTimer) clearTimeout(this.scanTimer); + if (this.enrichTimer) clearTimeout(this.enrichTimer); + this.scanTimer = null; + this.enrichTimer = null; + } + + private readRow(): typeof automationSettings.$inferSelect { + return this.database.db.select().from(automationSettings).where(eq(automationSettings.id, 1)).get()!; + } +} + +export function scheduleNext(schedule: AutomationScheduleDto, run: () => void, now = new Date()): NodeJS.Timeout | null { + if (schedule.frequency === "disabled") return null; + const next = nextRunAt(schedule, now); + return setTimeout(run, Math.max(1000, next.getTime() - now.getTime())); +} + +export function nextRunAt(schedule: AutomationScheduleDto, now = new Date()): Date { + const [hour, minute] = schedule.time.split(":").map(Number); + const next = new Date(now); + next.setUTCHours(hour, minute, 0, 0); + if (schedule.frequency === "weekly") { + const delta = (schedule.dayOfWeek - next.getUTCDay() + 7) % 7; + next.setUTCDate(next.getUTCDate() + delta); + } + if (next <= now) { + next.setUTCDate(next.getUTCDate() + (schedule.frequency === "weekly" ? 7 : 1)); + } + return next; +} + +function parseSchedule(value: string): AutomationScheduleDto { + try { + return normalizeSchedule(JSON.parse(value) as Partial); + } catch { + return defaultSchedule; + } +} + +function normalizeSchedule(value: Partial): AutomationScheduleDto { + return { + frequency: value.frequency ?? "disabled", + time: value.time ?? "03:00", + dayOfWeek: value.dayOfWeek ?? 1 + }; +} + +function isBookPath(filePath: string): boolean { + return /\.(epub|pdf|cbz|cbr)$/i.test(filePath); +} + +function watchLibraryDirs(root: string, listener: (event: string, filename: string | Buffer | null) => void): FSWatcher[] { + const watchers: FSWatcher[] = []; + for (const dir of walkDirs(root)) { + watchers.push(watch(dir, listener)); + } + return watchers; +} + +function* walkDirs(root: string): Generator { + yield root; + let entries: Dirent[]; + try { + entries = readdirSync(root, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isDirectory()) { + yield* walkDirs(join(root, entry.name)); + } + } +} diff --git a/apps/api/src/books/books.controller.ts b/apps/api/src/books/books.controller.ts index f73a961..e54b89a 100644 --- a/apps/api/src/books/books.controller.ts +++ b/apps/api/src/books/books.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Query, Res, UseGuards } from "@nestjs/common"; +import { Controller, Get, Headers, Param, Query, Res, UseGuards } from "@nestjs/common"; import { FastifyReply } from "fastify"; import { lookup } from "mime-types"; import { BookQueryDto, BookQuerySchema } from "@readabook/shared"; @@ -26,11 +26,31 @@ export class BooksController { return this.books.get(Number(id)); } + @Get(":id/pages") + pages(@Param("id") id: string) { + return this.books.listComicPages(Number(id)); + } + + @Get(":id/pages/:page") + async page(@Param("id") id: string, @Param("page") page: string, @Res() reply: FastifyReply) { + const result = await this.books.readComicPage(Number(id), Number(page)); + reply.header("Content-Type", result.contentType); + reply.header("Cache-Control", "private, max-age=3600"); + return reply.send(result.data); + } + @Get(":id/file") - file(@Param("id") id: string, @Res() reply: FastifyReply) { - const { book, stream } = this.books.streamFile(Number(id)); - reply.header("Content-Type", lookup(book.filePath) || "application/octet-stream"); - reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(book.title)}.${book.format}"`); + file(@Param("id") id: string, @Headers("range") range: string | undefined, @Res() reply: FastifyReply) { + const { book, stream, contentLength, contentType, end, partial, size, start } = this.books.streamFile(Number(id), range); + if (partial) { + reply.code(206); + reply.header("Content-Range", `bytes ${start}-${end}/${size}`); + } + reply.header("Accept-Ranges", "bytes"); + reply.header("Content-Length", String(contentLength)); + reply.header("Content-Type", contentType || lookup(book.filePath) || "application/octet-stream"); + reply.header("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(`${book.title}.${book.format}`)}`); + reply.header("X-Content-Type-Options", "nosniff"); return reply.send(stream); } 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..3903156 --- /dev/null +++ b/apps/api/src/books/books.service.test.ts @@ -0,0 +1,264 @@ +import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { extname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { BookQuerySchema } from "@readabook/shared"; +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 realBooksPath = "/home/anthony/Documents/Projects/ReadaBook/Books"; +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() && canReadRealBooksCorpus())("exposes every persisted real corpus book through the default catalogue query", () => { + const database = createDatabase(); + try { + const service = new BooksService(database); + const now = database.now(); + const library = database.db + .insert(libraries) + .values({ name: "Real corpus", path: realBooksPath, enabled: true, createdAt: now, updatedAt: now }) + .returning() + .get(); + const files = realCorpusBookFiles(); + + for (const [index, filePath] of files.entries()) { + database.db + .insert(books) + .values({ + libraryId: library.id, + seriesId: null, + title: `Corpus ${String(index + 1).padStart(3, "0")}`, + author: null, + description: null, + isbn: null, + isbn13: null, + identifiersJson: null, + localMetadataJson: null, + language: null, + publisher: null, + publishedDate: null, + volumeNumber: null, + volumeLabel: null, + format: bookFormatFromPath(filePath), + filePath, + coverPath: null, + metadataStatus: "none", + metadataProvenanceJson: JSON.stringify({ title: "local" }), + scanStatus: "succeeded", + enrichmentStatus: "idle", + fileSize: statSync(filePath).size, + fileMtime: statSync(filePath).mtime.toISOString(), + createdAt: now, + updatedAt: now + }) + .run(); + } + + expect(files.length).toBeGreaterThan(50); + expect(service.count()).toBe(files.length); + expect(service.list(BookQuerySchema.parse({}))).toHaveLength(files.length); + } finally { + database.onModuleDestroy(); + } + }); + + 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; + } +} + +function canReadRealBooksCorpus(): boolean { + try { + return realCorpusBookFiles().length > 0; + } catch { + return false; + } +} + +function realCorpusBookFiles(root = realBooksPath): string[] { + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return realCorpusBookFiles(path); + if (!entry.isFile()) return []; + return isBookFile(path) ? [path] : []; + }); +} + +function isBookFile(filePath: string): boolean { + return [".epub", ".pdf", ".cbz", ".cbr"].includes(extname(filePath).toLowerCase()); +} + +function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" | "cbr" { + const extension = extname(filePath).toLowerCase(); + if (extension === ".epub") return "epub"; + if (extension === ".cbz") return "cbz"; + if (extension === ".cbr") return "cbr"; + return "pdf"; +} diff --git a/apps/api/src/books/books.service.ts b/apps/api/src/books/books.service.ts index d024e19..73e7571 100644 --- a/apps/api/src/books/books.service.ts +++ b/apps/api/src/books/books.service.ts @@ -1,9 +1,13 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; -import { createReadStream, existsSync } from "node:fs"; +import { BadRequestException, HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common"; +import { createReadStream, existsSync, statSync } from "node:fs"; +import { extname } from "node:path"; import { and, eq, sql } from "drizzle-orm"; 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 { @@ -16,17 +20,33 @@ export class BooksService { if (query.q) { return this.search(query.q, query.limit, query.offset); } - return this.database.db + const statement = this.database.db .select() .from(books) .where(filters.length ? and(...filters) : undefined) - .orderBy(books.title) - .limit(query.limit) - .offset(query.offset) - .all(); + .orderBy(books.title); + const rows = + query.limit === undefined ? statement.all() : statement.limit(query.limit).offset(query.offset).all(); + return rows + .map((book) => this.mapBookSelect(book)); } - search(q: string, limit = 50, offset = 0) { + search(q: string, limit?: number, offset = 0) { + if (limit === undefined) { + const rows = this.database.sqlite + .prepare( + ` + SELECT books.* + FROM book_fts + JOIN books ON books.id = book_fts.rowid + WHERE book_fts MATCH ? + ORDER BY bm25(book_fts) + ` + ) + .all(`${q.replace(/"/g, '""')}*`); + return (rows as Array>).map((row) => this.mapBookRow(row)); + } + const rows = this.database.sqlite .prepare( ` @@ -39,10 +59,86 @@ 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) { + 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.getRecord(id); + if (!existsSync(book.filePath)) { + throw new NotFoundException("Book file not found on disk"); + } + const size = statSync(book.filePath).size; + const byteRange = parseByteRange(range, size); + const stream = createReadStream(book.filePath, { start: byteRange.start, end: byteRange.end }); + return { + book, + stream, + contentType: bookContentType(book.format), + contentLength: byteRange.end - byteRange.start + 1, + size, + ...byteRange + }; + } + + streamCover(id: number) { + const book = this.getRecord(id); + if (!book.coverPath || !existsSync(book.coverPath)) { + throw new NotFoundException("Cover not found"); + } + return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath }; + } + + async listComicPages(id: number) { + const book = this.getRecord(id); + this.assertComicArchiveBook(book); + const pages = book.format === "cbr" ? await listCbrImageEntries(book.filePath) : listCbzImageEntries(book.filePath); + return { + bookId: book.id, + pageCount: pages.length, + pages: pages.map((page, index) => ({ page: index + 1, name: page.name })) + }; + } + + async readComicPage(id: number, page: number) { + const book = this.getRecord(id); + this.assertComicArchiveBook(book); + try { + const result = + book.format === "cbr" + ? await readCbrPage(book.filePath, page, this.database.config.storageDir) + : readCbzPage(book.filePath, page); + return { book, page, contentType: lookupMime(result.entryName), data: result.data }; + } catch (error) { + throw new NotFoundException(error instanceof Error ? error.message : "Comic page not found"); + } + } + + count() { + 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"); @@ -50,48 +146,121 @@ export class BooksService { return book; } - streamFile(id: number) { - const book = this.get(id); + private assertComicArchiveBook(book: typeof books.$inferSelect): void { + if (book.format !== "cbz" && book.format !== "cbr") { + throw new BadRequestException("Book is not a comic archive"); + } if (!existsSync(book.filePath)) { throw new NotFoundException("Book file not found on disk"); } - return { book, stream: createReadStream(book.filePath) }; } - streamCover(id: number) { - const book = this.get(id); - if (!book.coverPath || !existsSync(book.coverPath)) { - throw new NotFoundException("Cover not found"); - } - return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath }; + 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) + }; } - count() { - return this.database.db.select({ count: sql`count(*)` }).from(books).get()?.count ?? 0; + 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 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), - 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 parseByteRange(range: string | undefined, size: number): { start: number; end: number; partial: boolean } { + if (!range) return { start: 0, end: Math.max(size - 1, 0), partial: false }; + const match = range.match(/^bytes=(\d*)-(\d*)$/); + if (!match || size <= 0) { + throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE); + } + + const [, rawStart, rawEnd] = match; + let start: number; + let end: number; + if (!rawStart && rawEnd) { + const suffixLength = Number(rawEnd); + start = Math.max(size - suffixLength, 0); + end = size - 1; + } else { + start = Number(rawStart); + end = rawEnd ? Number(rawEnd) : size - 1; + } + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= size) { + throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE); + } + return { start, end: Math.min(end, size - 1), partial: true }; +} + +function bookContentType(format: string): string { + if (format === "epub") return "application/epub+zip"; + if (format === "pdf") return "application/pdf"; + if (format === "cbz") return "application/vnd.comicbook+zip"; + if (format === "cbr") return "application/vnd.comicbook-rar"; + return "application/octet-stream"; +} + +function lookupMime(entryName: string): string { + const extension = extname(entryName).toLowerCase(); + if (extension === ".png") return "image/png"; + if (extension === ".webp") return "image/webp"; + if (extension === ".gif") return "image/gif"; + if (extension === ".avif") return "image/avif"; + return "image/jpeg"; } 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 new file mode 100644 index 0000000..76f44d2 --- /dev/null +++ b/apps/api/src/common/cbr.ts @@ -0,0 +1,64 @@ +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"; +import type { CbzPageEntry } from "./cbz.js"; + +export async function listCbrImageEntries(filePath: string): Promise { + const extractor = await createExtractorFromFile({ filepath: filePath }); + const list = extractor.getFileList(); + if (list.arcHeader.flags.volume) { + throw new Error("Multi-volume CBR archives are not supported"); + } + if (list.arcHeader.flags.headerEncrypted) { + throw new Error("Encrypted CBR archives are not supported"); + } + + const headers = [...list.fileHeaders]; + if (headers.length > MAX_COMIC_ARCHIVE_ENTRIES) { + throw new Error("CBR archive has too many entries"); + } + + const images = headers + .filter((header) => !header.flags.directory && !header.flags.encrypted) + .filter((header) => COMIC_IMAGE_EXTENSIONS.has(extname(header.name).toLowerCase())) + .map((header) => ({ entryName: header.name, name: header.name.split(/[\\/]/).pop() ?? header.name })) + .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true, sensitivity: "base" })); + + if (!images.length) { + throw new Error("CBR archive does not contain readable image pages"); + } + + return images; +} + +export async function readCbrPage( + filePath: string, + pageNumber: number, + storageDir: string +): Promise<{ entryName: string; data: Buffer }> { + if (!Number.isInteger(pageNumber) || pageNumber < 1) { + throw new Error("CBR page number must be a positive integer"); + } + const pages = await listCbrImageEntries(filePath); + const page = pages[pageNumber - 1]; + if (!page) { + 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 { + const extractor = await createExtractorFromFile({ + filepath: filePath, + targetPath: tempDir, + filenameTransform: () => safeName + }); + const extracted = extractor.extract({ files: [page.entryName] }); + [...extracted.files]; + return { entryName: page.entryName, data: readFileSync(join(tempDir, safeName)) }; + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} diff --git a/apps/api/src/common/cbz.ts b/apps/api/src/common/cbz.ts new file mode 100644 index 0000000..1575518 --- /dev/null +++ b/apps/api/src/common/cbz.ts @@ -0,0 +1,46 @@ +import { extname } from "node:path"; +import AdmZip from "adm-zip"; + +export type CbzPageEntry = { + entryName: string; + name: string; +}; + +export const COMIC_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]); +export const MAX_COMIC_ARCHIVE_ENTRIES = 20000; + +export function listCbzImageEntries(filePath: string): CbzPageEntry[] { + const zip = new AdmZip(filePath); + const entries = zip.getEntries(); + if (entries.length > MAX_COMIC_ARCHIVE_ENTRIES) { + throw new Error("CBZ archive has too many entries"); + } + + const images = entries + .filter((entry) => !entry.isDirectory && COMIC_IMAGE_EXTENSIONS.has(extname(entry.entryName).toLowerCase())) + .map((entry) => ({ entryName: entry.entryName, name: entry.name })) + .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true, sensitivity: "base" })); + + if (!images.length) { + throw new Error("CBZ archive does not contain readable image pages"); + } + + return images; +} + +export function readCbzPage(filePath: string, pageNumber: number): { entryName: string; data: Buffer } { + if (!Number.isInteger(pageNumber) || pageNumber < 1) { + throw new Error("CBZ page number must be a positive integer"); + } + const zip = new AdmZip(filePath); + const pages = listCbzImageEntries(filePath); + const page = pages[pageNumber - 1]; + if (!page) { + throw new Error("CBZ page not found"); + } + const entry = zip.getEntry(page.entryName); + if (!entry) { + throw new Error("CBZ page not found"); + } + return { entryName: entry.entryName, data: entry.getData() }; +} diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 4aba511..cc09fd9 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -11,8 +11,15 @@ export type AppConfig = { cookieName: string; cookieSecure: boolean; openLibraryEnabled: boolean; + libraryPathAliases: Array<{ from: string; to: string }>; + initialAdminEmail: string; + initialAdminPassword: string; + initialAdminPasswordIsDefault: boolean; }; +const DEFAULT_INITIAL_ADMIN_EMAIL = "admin@readabook.local"; +const DEFAULT_INITIAL_ADMIN_PASSWORD = "readabook-admin-change-me"; + export function loadConfig(): AppConfig { const databasePath = resolve(process.env.DATABASE_PATH ?? "./data/readabook.sqlite"); const storageDir = resolve(process.env.STORAGE_DIR ?? "./data/storage"); @@ -29,6 +36,26 @@ export function loadConfig(): AppConfig { jwtSecret: process.env.JWT_SECRET ?? "dev-change-me-readabook", cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session", cookieSecure: process.env.COOKIE_SECURE === "true", - openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false" + openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false", + libraryPathAliases: parseLibraryPathAliases(process.env.LIBRARY_PATH_ALIASES), + initialAdminEmail: process.env.INITIAL_ADMIN_EMAIL ?? DEFAULT_INITIAL_ADMIN_EMAIL, + initialAdminPassword: process.env.INITIAL_ADMIN_PASSWORD ?? DEFAULT_INITIAL_ADMIN_PASSWORD, + initialAdminPasswordIsDefault: !process.env.INITIAL_ADMIN_PASSWORD }; } + +function parseLibraryPathAliases(value: string | undefined): Array<{ from: string; to: string }> { + if (!value) return []; + return value + .split(";") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const separator = entry.indexOf("="); + if (separator === -1) return null; + const from = entry.slice(0, separator).trim(); + const to = entry.slice(separator + 1).trim(); + return from && to ? { from, to } : null; + }) + .filter((entry): entry is { from: string; to: string } => Boolean(entry)); +} diff --git a/apps/api/src/database/database.service.test.ts b/apps/api/src/database/database.service.test.ts new file mode 100644 index 0000000..d2303bd --- /dev/null +++ b/apps/api/src/database/database.service.test.ts @@ -0,0 +1,243 @@ +import Database from "better-sqlite3"; +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.service.js"; +import { books, libraries, series } from "./schema.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("database migrations", () => { + it.runIf(canLoadBetterSqlite())("adds metadata columns to an existing comic-capable books table before creating dependent indexes", () => { + const dir = mkdtempSync(join(tmpdir(), "readabook-migration-")); + tempDirs.push(dir); + const databasePath = join(dir, "readabook.sqlite"); + 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, + email TEXT NOT NULL UNIQUE, + name TEXT, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE libraries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + path TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, + title TEXT NOT NULL, + author TEXT, + description TEXT, + isbn TEXT, + language TEXT, + publisher TEXT, + published_date TEXT, + format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')), + file_path TEXT NOT NULL UNIQUE, + cover_path TEXT, + file_size INTEGER NOT NULL, + file_mtime TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE, + locator TEXT NOT NULL, + percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, book_id) + ); + + CREATE TABLE jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed')), + detail TEXT, + error TEXT, + 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(); + + process.env.DATABASE_PATH = databasePath; + process.env.STORAGE_DIR = storageDir; + + const database = new DatabaseService(); + 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(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 { + try { + new Database(":memory:").close(); + return true; + } catch { + return false; + } +} diff --git a/apps/api/src/database/database.service.ts b/apps/api/src/database/database.service.ts index 21b9ec0..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,19 +50,39 @@ 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, isbn TEXT, + isbn13 TEXT, + identifiers_json TEXT, + local_metadata_json TEXT, language TEXT, publisher TEXT, published_date TEXT, - format TEXT NOT NULL CHECK (format IN ('epub','pdf')), + 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, @@ -89,6 +110,26 @@ export class DatabaseService implements OnModuleDestroy { updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS 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 + ); + + CREATE TABLE IF NOT EXISTS automation_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + watch_libraries INTEGER NOT NULL DEFAULT 0, + auto_enrich_new_books INTEGER NOT NULL DEFAULT 1, + isbn_priority_enabled INTEGER NOT NULL DEFAULT 1, + scan_schedule_json TEXT NOT NULL, + enrich_schedule_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE VIRTUAL TABLE IF NOT EXISTS book_fts USING fts5( title, author, @@ -119,6 +160,333 @@ export class DatabaseService implements OnModuleDestroy { VALUES (new.id, new.title, new.author, new.description, new.isbn); 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(); this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')"); } + + private ensureBooksSupportsComicArchives(): void { + const table = this.sqlite + .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'books'") + .get() as { sql?: string } | undefined; + if (!table?.sql || (table.sql.includes("'cbz'") && table.sql.includes("'cbr'"))) return; + + this.sqlite.exec(` + PRAGMA foreign_keys = OFF; + PRAGMA legacy_alter_table = ON; + + DROP TRIGGER IF EXISTS books_ai; + DROP TRIGGER IF EXISTS books_ad; + DROP TRIGGER IF EXISTS books_au; + + BEGIN; + ALTER TABLE books RENAME TO books_legacy_format; + 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, + isbn TEXT, + isbn13 TEXT, + identifiers_json TEXT, + local_metadata_json TEXT, + 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, + updated_at TEXT NOT NULL + ); + INSERT INTO books ( + id, library_id, title, author, description, isbn, isbn13, identifiers_json, local_metadata_json, language, publisher, published_date, + 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, + 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; + + PRAGMA legacy_alter_table = OFF; + PRAGMA foreign_keys = ON; + + CREATE UNIQUE INDEX IF NOT EXISTS books_file_path_unique ON books(file_path); + CREATE INDEX IF NOT EXISTS books_library_idx ON books(library_id); + CREATE INDEX IF NOT EXISTS books_title_idx ON books(title); + CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13); + + CREATE TRIGGER IF NOT EXISTS books_ai AFTER INSERT ON books BEGIN + INSERT INTO book_fts(rowid, title, author, description, isbn) + VALUES (new.id, new.title, new.author, new.description, new.isbn); + END; + + CREATE TRIGGER IF NOT EXISTS books_ad AFTER DELETE ON books BEGIN + INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn) + VALUES('delete', old.id, old.title, old.author, old.description, old.isbn); + END; + + CREATE TRIGGER IF NOT EXISTS books_au AFTER UPDATE ON books BEGIN + INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn) + VALUES('delete', old.id, old.title, old.author, old.description, old.isbn); + INSERT INTO book_fts(rowid, title, author, description, isbn) + VALUES (new.id, new.title, new.author, new.description, new.isbn); + END; + `); + } + + private ensureBooksMetadataColumns(): void { + const columns = this.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>; + const names = new Set(columns.map((column) => column.name)); + if (!names.has("isbn13")) { + this.sqlite.exec("ALTER TABLE books ADD COLUMN isbn13 TEXT"); + } + if (!names.has("identifiers_json")) { + this.sqlite.exec("ALTER TABLE books ADD COLUMN identifiers_json TEXT"); + } + 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 { + this.sqlite.exec(` + CREATE TABLE IF NOT EXISTS reader_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE, + mode TEXT NOT NULL CHECK (mode IN ('paged','scrolled','horizontal','vertical')), + fit TEXT CHECK (fit IN ('page','width','height','auto')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, book_id) + ); + CREATE INDEX IF NOT EXISTS reader_preferences_user_idx ON reader_preferences(user_id); + `); + } + + private ensureMetadataSourceConfigColumns(): void { + const names = this.columnNames("metadata_source_config"); + const now = sqlString(this.now()); + if (!names.has("enabled")) { + this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1"); + } + if (!names.has("priority")) { + this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN priority INTEGER NOT NULL DEFAULT 0"); + } + if (!names.has("api_key")) { + this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN api_key TEXT"); + } + if (!names.has("created_at")) { + this.sqlite.exec(`ALTER TABLE metadata_source_config ADD COLUMN created_at TEXT NOT NULL DEFAULT ${now}`); + } + if (!names.has("updated_at")) { + this.sqlite.exec(`ALTER TABLE metadata_source_config ADD COLUMN updated_at TEXT NOT NULL DEFAULT ${now}`); + } + } + + 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()); + const disabledScan = sqlString(JSON.stringify({ frequency: "disabled", time: "03:00", dayOfWeek: 1 })); + const disabledEnrich = sqlString(JSON.stringify({ frequency: "disabled", time: "04:00", dayOfWeek: 1 })); + if (!names.has("watch_libraries")) { + this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN watch_libraries INTEGER NOT NULL DEFAULT 0"); + } + if (!names.has("auto_enrich_new_books")) { + this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN auto_enrich_new_books INTEGER NOT NULL DEFAULT 1"); + } + if (!names.has("isbn_priority_enabled")) { + this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN isbn_priority_enabled INTEGER NOT NULL DEFAULT 1"); + } + if (!names.has("scan_schedule_json")) { + this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN scan_schedule_json TEXT NOT NULL DEFAULT ${disabledScan}`); + } + if (!names.has("enrich_schedule_json")) { + this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN enrich_schedule_json TEXT NOT NULL DEFAULT ${disabledEnrich}`); + } + if (!names.has("created_at")) { + this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN created_at TEXT NOT NULL DEFAULT ${now}`); + } + if (!names.has("updated_at")) { + this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN updated_at TEXT NOT NULL DEFAULT ${now}`); + } + } + + private columnNames(table: string): Set { + const columns = this.sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; + return new Set(columns.map((column) => column.name)); + } + + private ensureMetadataDefaults(): void { + const now = this.now(); + const insertSource = this.sqlite.prepare(` + INSERT INTO metadata_source_config (provider, enabled, priority, api_key, created_at, updated_at) + VALUES (?, ?, ?, NULL, ?, ?) + ON CONFLICT(provider) DO NOTHING + `); + insertSource.run("local", 1, 0, now, now); + 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( + ` + INSERT INTO automation_settings ( + id, watch_libraries, auto_enrich_new_books, isbn_priority_enabled, + scan_schedule_json, enrich_schedule_json, created_at, updated_at + ) + VALUES (1, 0, 1, 1, ?, ?, ?, ?) + ON CONFLICT(id) DO NOTHING + ` + ) + .run( + JSON.stringify({ frequency: "disabled", time: "03:00", dayOfWeek: 1 }), + JSON.stringify({ frequency: "disabled", time: "04:00", dayOfWeek: 1 }), + now, + now + ); + } +} + +function sqlString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; } diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts index 7a551e6..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,16 +44,26 @@ 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"), isbn: text("isbn"), + isbn13: text("isbn13"), + identifiersJson: text("identifiers_json"), + localMetadataJson: text("local_metadata_json"), language: text("language"), publisher: text("publisher"), publishedDate: text("published_date"), - format: text("format", { enum: ["epub", "pdf"] }).notNull(), + 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(), @@ -48,6 +72,24 @@ export const books = sqliteTable( (table) => ({ filePathIdx: uniqueIndex("books_file_path_unique").on(table.filePath) }) ); +export const readerPreferences = sqliteTable( + "reader_preferences", + { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + bookId: integer("book_id") + .notNull() + .references(() => books.id, { onDelete: "cascade" }), + mode: text("mode", { enum: ["paged", "scrolled", "horizontal", "vertical"] }).notNull(), + fit: text("fit", { enum: ["page", "width", "height", "auto"] }), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull() + }, + (table) => ({ userBookIdx: uniqueIndex("reader_preferences_user_book_unique").on(table.userId, table.bookId) }) +); + export const progress = sqliteTable( "progress", { @@ -75,3 +117,23 @@ export const jobs = sqliteTable("jobs", { createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull() }); + +export const metadataSourceConfig = sqliteTable("metadata_source_config", { + 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"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull() +}); + +export const automationSettings = sqliteTable("automation_settings", { + id: integer("id").primaryKey(), + watchLibraries: integer("watch_libraries", { mode: "boolean" }).notNull().default(false), + autoEnrichNewBooks: integer("auto_enrich_new_books", { mode: "boolean" }).notNull().default(true), + isbnPriorityEnabled: integer("isbn_priority_enabled", { mode: "boolean" }).notNull().default(true), + scanScheduleJson: text("scan_schedule_json").notNull(), + enrichScheduleJson: text("enrich_schedule_json").notNull(), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull() +}); diff --git a/apps/api/src/jobs/jobs.service.ts b/apps/api/src/jobs/jobs.service.ts index f219825..cab98a5 100644 --- a/apps/api/src/jobs/jobs.service.ts +++ b/apps/api/src/jobs/jobs.service.ts @@ -1,12 +1,16 @@ -import { Injectable } from "@nestjs/common"; -import { desc, eq } from "drizzle-orm"; +import { Injectable, OnModuleInit } from "@nestjs/common"; +import { desc, eq, inArray } from "drizzle-orm"; import { DatabaseService } from "../database/database.service.js"; import { jobs } from "../database/schema.js"; @Injectable() -export class JobsService { +export class JobsService implements OnModuleInit { constructor(private readonly database: DatabaseService) {} + onModuleInit(): void { + this.failInterruptedJobs(); + } + create(type: string, detail?: string) { const now = this.database.now(); return this.database.db @@ -43,4 +47,16 @@ export class JobsService { list(limit = 50) { return this.database.db.select().from(jobs).orderBy(desc(jobs.createdAt)).limit(limit).all(); } + + private failInterruptedJobs(): void { + this.database.db + .update(jobs) + .set({ + status: "failed", + error: "Job interrupted before completion, most likely by API shutdown or restart", + updatedAt: this.database.now() + }) + .where(inArray(jobs.status, ["queued", "running"])) + .run(); + } } diff --git a/apps/api/src/libraries/libraries.service.ts b/apps/api/src/libraries/libraries.service.ts index e8d40df..d73f206 100644 --- a/apps/api/src/libraries/libraries.service.ts +++ b/apps/api/src/libraries/libraries.service.ts @@ -1,10 +1,9 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; -import { accessSync, constants, realpathSync, statSync } from "node:fs"; -import { resolve } from "node:path"; -import { eq } from "drizzle-orm"; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; +import { and, eq, ne } from "drizzle-orm"; import { CreateLibraryDto, UpdateLibraryDto } from "@readabook/shared"; import { DatabaseService } from "../database/database.service.js"; import { libraries } from "../database/schema.js"; +import { LibraryPathValidationError, resolveLibraryPath } from "./library-path.js"; @Injectable() export class LibrariesService { @@ -24,6 +23,7 @@ export class LibrariesService { create(input: CreateLibraryDto) { const path = this.validatePath(input.path); + this.ensurePathUnused(path); const now = this.database.now(); return this.database.db .insert(libraries) @@ -35,7 +35,10 @@ export class LibrariesService { update(id: number, input: UpdateLibraryDto) { const values: Partial = { updatedAt: this.database.now() }; if (input.name) values.name = input.name; - if (input.path) values.path = this.validatePath(input.path); + if (input.path) { + values.path = this.validatePath(input.path); + this.ensurePathUnused(values.path, id); + } if (input.enabled !== undefined) values.enabled = input.enabled; const library = this.database.db.update(libraries).set(values).where(eq(libraries.id, id)).returning().get(); if (!library) { @@ -49,17 +52,29 @@ export class LibrariesService { } private validatePath(input: string): string { - const resolved = resolve(input); try { - accessSync(resolved, constants.R_OK); - const stats = statSync(resolved); - if (!stats.isDirectory()) { - throw new BadRequestException("Library path must be a directory"); - } - return realpathSync(resolved); + return resolveLibraryPath(input, this.database.config.libraryPathAliases); } catch (error) { - if (error instanceof BadRequestException) throw error; - throw new BadRequestException("Library path is not readable"); + if (error instanceof LibraryPathValidationError) { + throw new BadRequestException({ + code: error.code, + message: error.message, + path: error.path + }); + } + throw error; + } + } + + private ensurePathUnused(path: string, exceptId?: number): void { + const where = exceptId === undefined ? eq(libraries.path, path) : and(eq(libraries.path, path), ne(libraries.id, exceptId)); + const existing = this.database.db.select({ id: libraries.id }).from(libraries).where(where).get(); + if (existing) { + throw new ConflictException({ + code: "LIBRARY_PATH_ALREADY_USED", + message: "Library path is already used", + path + }); } } } diff --git a/apps/api/src/libraries/library-path.test.ts b/apps/api/src/libraries/library-path.test.ts new file mode 100644 index 0000000..cd9774f --- /dev/null +++ b/apps/api/src/libraries/library-path.test.ts @@ -0,0 +1,49 @@ +import { closeSync, existsSync, mkdtempSync, openSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { LibraryPathValidationError, resolveLibraryPath } from "./library-path.js"; + +const tempDirs: string[] = []; +const realBooksPath = "/home/anthony/Documents/Projects/ReadaBook/Books"; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("library path resolution", () => { + it("resolves a readable directory", () => { + const dir = mkdtempSync(join(tmpdir(), "readabook-library-")); + tempDirs.push(dir); + + expect(resolveLibraryPath(dir)).toBe(resolve(dir)); + }); + + it("maps a host path alias to the mounted container path", () => { + const hostRoot = "/host/project/Books"; + const mountedRoot = mkdtempSync(join(tmpdir(), "readabook-mounted-books-")); + tempDirs.push(mountedRoot); + + expect(resolveLibraryPath(hostRoot, [{ from: hostRoot, to: mountedRoot }])).toBe(resolve(mountedRoot)); + }); + + it("rejects regular files with a stable code", () => { + const dir = mkdtempSync(join(tmpdir(), "readabook-library-")); + tempDirs.push(dir); + const file = join(dir, "book.epub"); + closeSync(openSync(file, "w")); + + expect(() => resolveLibraryPath(file)).toThrowError(LibraryPathValidationError); + try { + resolveLibraryPath(file); + } catch (error) { + expect(error).toMatchObject({ code: "LIBRARY_PATH_NOT_DIRECTORY" }); + } + }); + + it.runIf(existsSync(realBooksPath))("accepts the real Books corpus path used by QA", () => { + expect(resolveLibraryPath(realBooksPath)).toBe(resolve(realBooksPath)); + }); +}); diff --git a/apps/api/src/libraries/library-path.ts b/apps/api/src/libraries/library-path.ts new file mode 100644 index 0000000..79b0541 --- /dev/null +++ b/apps/api/src/libraries/library-path.ts @@ -0,0 +1,72 @@ +import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { relative, resolve, sep } from "node:path"; + +export type LibraryPathAlias = { + from: string; + to: string; +}; + +export type LibraryPathErrorCode = "LIBRARY_PATH_NOT_FOUND" | "LIBRARY_PATH_NOT_DIRECTORY" | "LIBRARY_PATH_NOT_READABLE"; + +export class LibraryPathValidationError extends Error { + constructor( + public readonly code: LibraryPathErrorCode, + public readonly path: string + ) { + super(messageForCode(code)); + } +} + +export function resolveLibraryPath(input: string, aliases: LibraryPathAlias[] = []): string { + const candidates = candidatePaths(input, aliases); + let firstError: LibraryPathValidationError | null = null; + + for (const candidate of candidates) { + try { + const stats = statSync(candidate); + if (!stats.isDirectory()) { + throw new LibraryPathValidationError("LIBRARY_PATH_NOT_DIRECTORY", candidate); + } + accessSync(candidate, constants.R_OK | constants.X_OK); + return realpathSync(candidate); + } catch (error) { + firstError ??= normalizePathError(error, candidate); + } + } + + throw firstError ?? new LibraryPathValidationError("LIBRARY_PATH_NOT_FOUND", resolve(input)); +} + +function candidatePaths(input: string, aliases: LibraryPathAlias[]): string[] { + const resolved = resolve(input); + const candidates = [resolved]; + + for (const alias of aliases) { + const from = resolve(alias.from); + const to = resolve(alias.to); + const remainder = relative(from, resolved); + if (remainder === "" || (!remainder.startsWith("..") && remainder !== ".." && !remainder.startsWith(`..${sep}`))) { + candidates.push(resolve(to, remainder)); + } + } + + return [...new Set(candidates)]; +} + +function normalizePathError(error: unknown, path: string): LibraryPathValidationError { + if (error instanceof LibraryPathValidationError) return error; + const code = typeof error === "object" && error && "code" in error ? String(error.code) : ""; + if (code === "ENOENT" || code === "ENOTDIR") { + return new LibraryPathValidationError("LIBRARY_PATH_NOT_FOUND", path); + } + if (code === "EACCES" || code === "EPERM") { + return new LibraryPathValidationError("LIBRARY_PATH_NOT_READABLE", path); + } + return new LibraryPathValidationError("LIBRARY_PATH_NOT_READABLE", path); +} + +function messageForCode(code: LibraryPathErrorCode): string { + if (code === "LIBRARY_PATH_NOT_FOUND") return "Library path does not exist"; + if (code === "LIBRARY_PATH_NOT_DIRECTORY") return "Library path must be a directory"; + return "Library path is not readable"; +} diff --git a/apps/api/src/metadata/adapters/bnf.provider.ts b/apps/api/src/metadata/adapters/bnf.provider.ts new file mode 100644 index 0000000..bf35efa --- /dev/null +++ b/apps/api/src/metadata/adapters/bnf.provider.ts @@ -0,0 +1,96 @@ +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 }); + +@Injectable() +export class BnfProvider implements MetadataProvider { + readonly id = "bnf" as const; + + async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise { + const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10; + if (!isbn) { + const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, _config); + return matches[0] ?? null; + } + const query = `bib.isbn all "${isbn}"`; + const matches = await this.searchSru(query, 1, lookup.identifiers.isbn13); + return matches[0] ?? null; + } + + async searchByMetadata(query: MetadataSearchQuery, _config: MetadataProviderConfig): Promise { + const title = query.title.replace(/"/g, " "); + const author = query.author?.replace(/"/g, " "); + const sruQuery = [`bib.title all "${title}"`, author ? `bib.author all "${author}"` : null, query.year ? `bib.date all "${query.year}"` : null] + .filter(Boolean) + .join(" and "); + return this.searchSru(sruQuery, 5, query.isbn ? toIsbn13(query.isbn) : null); + } + + private async searchSru(query: string, maximumRecords: number, expectedIsbn13: string | null): Promise { + const url = new URL("https://catalogue.bnf.fr/api/SRU"); + url.searchParams.set("version", "1.2"); + url.searchParams.set("operation", "searchRetrieve"); + url.searchParams.set("query", query); + url.searchParams.set("maximumRecords", String(maximumRecords)); + + 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) + .filter((record): record is Record => Boolean(record)); + if (!records.length) return []; + return records + .map((record) => { + const fields = asArray(record.datafield); + return { + title: subfield(fields, "200", "a") ?? undefined, + author: subfield(fields, "200", "f") ?? ([subfield(fields, "700", "b"), subfield(fields, "700", "a")].filter(Boolean).join(" ") || null), + description: subfield(fields, "330", "a"), + isbn: bestIsbn(fields, expectedIsbn13), + language: subfield(fields, "101", "a"), + publisher: subfield(fields, "210", "c") ?? subfield(fields, "214", "c"), + publishedDate: normalizePublishedDate(cleanDate(subfield(fields, "210", "d") ?? subfield(fields, "214", "d"))) + }; + }) + .sort((left, right) => Number(Boolean(right.isbn)) - Number(Boolean(left.isbn))); + } +} + +function asArray(value: unknown): Array> { + if (!value) return []; + return Array.isArray(value) ? (value as Array>) : [value as Record]; +} + +function field(fields: Array>, tag: string): Record | undefined { + return fields.find((item) => item["@_tag"] === tag); +} + +function subfield(fields: Array>, tag: string, code: string): string | null { + const subfields = asArray(field(fields, tag)?.subfield); + const value = subfields.find((item) => item["@_code"] === code)?.["#text"]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function bestIsbn(fields: Array>, expectedIsbn13: string | null): string | null { + const values = fields + .filter((item) => item["@_tag"] === "073" || item["@_tag"] === "010") + .flatMap((item) => asArray(item.subfield)) + .filter((item) => item["@_code"] === "a") + .map((item) => String(item["#text"] ?? "").replace(/[^0-9X]/gi, "")) + .filter(Boolean); + return ( + values.find((candidate) => Boolean(expectedIsbn13) && toIsbn13(candidate) === expectedIsbn13) ?? + values.find((candidate) => Boolean(toIsbn13(candidate))) ?? + null + ); +} + +function cleanDate(value: string | null): string | null { + return value?.match(/\d{4}/)?.[0] ?? value; +} 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 new file mode 100644 index 0000000..1fa6d74 --- /dev/null +++ b/apps/api/src/metadata/adapters/google-books.provider.ts @@ -0,0 +1,201 @@ +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 { + readonly id = "googlebooks" as const; + + async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise { + const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10; + if (!isbn) { + const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, config); + return matches[0] ?? null; + } + 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", googleQuery); + url.searchParams.set("maxResults", "10"); + url.searchParams.set("printType", "books"); + if (config.apiKey) url.searchParams.set("key", config.apiKey); + + 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) => ({ 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: 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; +} + +function arrayJoin(value: unknown): string | null { + return Array.isArray(value) && value.length ? value.map(String).join(", ") : null; +} + +function isbnFromIndustryIdentifiers(value: unknown, expectedIsbn13: string | null): string | null { + if (!Array.isArray(value)) return null; + const entries = value as Array<{ type?: unknown; identifier?: unknown }>; + const matching = entries.find((entry) => Boolean(expectedIsbn13) && toIsbn13(stringValue(entry.identifier) ?? "") === expectedIsbn13)?.identifier; + if (matching) return stringValue(matching); + const isbn13 = entries.find((entry) => entry.type === "ISBN_13")?.identifier; + 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 new file mode 100644 index 0000000..8ac1762 --- /dev/null +++ b/apps/api/src/metadata/adapters/local.provider.ts @@ -0,0 +1,28 @@ +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 { + readonly id = "local" as const; + + async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise { + return { + title: lookup.title, + author: lookup.author, + isbn: lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10, + identifiers: lookup.identifiers + }; + } + + async searchByMetadata(query: MetadataSearchQuery, _config: MetadataProviderConfig): Promise { + return [ + { + title: query.title, + author: query.author, + isbn: query.isbn ?? 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 new file mode 100644 index 0000000..2683b16 --- /dev/null +++ b/apps/api/src/metadata/adapters/open-library.provider.ts @@ -0,0 +1,148 @@ +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 { + readonly id = "openlibrary" as const; + + async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise { + const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10; + if (isbn) { + return this.lookupIsbn(isbn, lookup.identifiers.isbn13); + } + if (lookup.sourceId) { + return this.lookupEdition(lookup.sourceId, lookup.identifiers.isbn13); + } + 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 url = new URL("https://openlibrary.org/search.json"); + url.searchParams.set("title", query.title); + 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 providerFetch(this.id, url, { + headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" }, + timeoutMs: 4000 + }); + 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, + sourceId: firstArrayValue(doc.edition_key) ?? stringValue(doc.cover_edition_key), + author: arrayJoin(doc.author_name), + language: firstArrayValue(doc.language), + publisher: firstArrayValue(doc.publisher), + 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 providerFetch(this.id, `https://openlibrary.org/books/${encodeURIComponent(editionKey)}.json`, { + headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" }, + timeoutMs: 4000 + }); + 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 providerFetch(this.id, `https://openlibrary.org/isbn/${encodeURIComponent(isbn)}.json`, { + headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" }, + timeoutMs: 4000 + }); + 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 editionToMatch(edition: Record, expectedIsbn13: string | null): Promise { + const author = await this.lookupAuthorName(edition.authors); + return { + title: stringValue(edition.title) ?? undefined, + author, + description: descriptionValue(edition.description), + isbn: bestIsbn([...(asStringArray(edition.isbn_13)), ...(asStringArray(edition.isbn_10))], expectedIsbn13), + language: languageValue(edition.languages), + publisher: firstArrayValue(edition.publishers), + 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 providerFetch(this.id, `https://openlibrary.org${key}.json`, { + headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" }, + timeoutMs: 3000 + }); + 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); + } +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function firstArrayValue(value: unknown): string | null { + if (!Array.isArray(value) || !value.length) return null; + return String(value[0]); +} + +function arrayJoin(value: unknown): string | null { + return Array.isArray(value) && value.length ? value.map(String).join(", ") : null; +} + +function bestIsbn(value: unknown, expectedIsbn13: string | null): string | null { + if (!Array.isArray(value)) return null; + const values = value.map(String); + return ( + values.find((candidate) => Boolean(expectedIsbn13) && toIsbn13(candidate) === expectedIsbn13) ?? + values.find((candidate) => Boolean(toIsbn13(candidate))) ?? + null + ); +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.map(String) : []; +} + +function descriptionValue(value: unknown): string | null { + if (typeof value === "string") return value.trim() || null; + if (typeof value === "object" && value && "value" in value) return stringValue(value.value); + return null; +} + +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-identifiers.test.ts b/apps/api/src/metadata/extract-identifiers.test.ts new file mode 100644 index 0000000..e615996 --- /dev/null +++ b/apps/api/src/metadata/extract-identifiers.test.ts @@ -0,0 +1,32 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { ExtractIdentifiers, normalizeIsbn, toIsbn13 } from "./use-cases/extract-identifiers.js"; + +describe("ISBN normalization", () => { + it("accepts valid ISBN-10/13 and rejects bad checksums", () => { + expect(normalizeIsbn("0-306-40615-2")).toBe("0306406152"); + expect(normalizeIsbn("978-0-306-40615-7")).toBe("9780306406157"); + expect(normalizeIsbn("978-0-306-40615-8")).toBeNull(); + expect(normalizeIsbn("5030931067112")).toBeNull(); + }); + + it("converts ISBN-10 to ISBN-13", () => { + expect(toIsbn13("0-306-40615-2")).toBe("9780306406157"); + }); +}); + +describe("ExtractIdentifiers", () => { + it("uses embedded PDF text before filename fallback candidates", () => { + const dir = mkdtempSync(join(tmpdir(), "readabook-isbn-")); + const file = join(dir, "fallback 9780306406157.pdf"); + writeFileSync(file, "%PDF-1.4\n1 0 obj << /Title (Book) /Subject (ISBN 0-306-40615-2) >> endobj"); + + const identifiers = new ExtractIdentifiers().fromMetadataAndFile({ isbn: null }, file); + + expect(identifiers.isbn10).toBe("0306406152"); + expect(identifiers.isbn13).toBe("9780306406157"); + expect(identifiers.candidates).toContain("9780306406157"); + }); +}); 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 new file mode 100644 index 0000000..53bab86 --- /dev/null +++ b/apps/api/src/metadata/local-metadata-hints.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { ExtractLocalMetadataHints } from "./use-cases/extract-local-metadata-hints.js"; +import { ScoreMetadataMatch } from "./use-cases/score-metadata-match.js"; + +describe("local metadata hints", () => { + it("extracts title, author and year hints from a book without ISBN", () => { + const hints = new ExtractLocalMetadataHints().fromMetadataAndFile( + { + title: "Harry Potter et le Prince de Sang Mele", + author: null, + description: null, + isbn: null, + language: null, + publisher: null, + publishedDate: null, + coverPath: null + }, + "/library/Harry Potter et le Prince de Sang Mele (J. K. Rowling) 2005.epub" + ); + + expect(hints).toMatchObject({ + title: "Harry Potter et le Prince de Sang Mele", + author: "J. K. Rowling", + year: "2005", + isbn: null + }); + }); +}); + +describe("metadata match scoring", () => { + it("keeps the best remote match for locally extracted title and author", () => { + const best = new ScoreMetadataMatch().best( + { title: "Harry Potter et le Prince de Sang Mele", author: "J. K. Rowling", year: "2005" }, + [ + { title: "Harry Potter et la chambre des secrets", author: "J. K. Rowling", publishedDate: "1998" }, + { title: "Harry Potter et le Prince de sang-mêlé", author: "J.K. Rowling", publishedDate: "2005" } + ] + ); + + expect(best?.match.title).toBe("Harry Potter et le Prince de sang-mêlé"); + 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 new file mode 100644 index 0000000..df6cc28 --- /dev/null +++ b/apps/api/src/metadata/metadata-providers.test.ts @@ -0,0 +1,410 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BnfProvider } from "./adapters/bnf.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 = { + title: "Harry Potter et la Chambre des Secrets", + author: "J. K. Rowling", + filePath: "/library/HP/Harry Potter et la Chambre des Secrets (J.K. Rowling).epub", + identifiers: { isbn10: null, isbn13: "9782070612376", candidates: ["9782070612376"] }, + local: { + title: "Harry Potter et la Chambre des Secrets", + author: "J. K. Rowling", + year: null, + isbn: "9782070612376", + fileTitle: "Harry Potter et la Chambre des Secrets (J.K. Rowling)", + raw: { + title: "Harry Potter et la Chambre des Secrets", + author: "J. K. Rowling", + publishedDate: null, + fileName: "Harry Potter et la Chambre des Secrets (J.K. Rowling)" + } + } +}; + +const config: MetadataProviderConfig = { provider: "openlibrary", enabled: true, priority: 1, apiKey: null }; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("metadata providers", () => { + it("queries OpenLibrary by ISBN and normalizes the matching book", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + title: "Harry Potter et la Chambre des Secrets", + authors: [{ key: "/authors/OL23919A" }], + languages: [{ key: "/languages/fre" }], + publishers: ["Gallimard jeunesse"], + publish_date: "2007-03", + isbn_13: ["9782070612376"], + isbn_10: ["2070612379"] + }) + ) + .mockResolvedValueOnce(jsonResponse({ name: "J. K. Rowling" })); + vi.stubGlobal("fetch", fetchMock); + + const result = await new OpenLibraryProvider().lookup(lookup, config); + + expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toBe("https://openlibrary.org/isbn/9782070612376.json"); + expect(result).toMatchObject({ + title: "Harry Potter et la Chambre des Secrets", + author: "J. K. Rowling", + isbn: "9782070612376" + }); + }); + + 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); + + 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"); + }); + + 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 () => { + const fetchMock = vi.fn(async () => + jsonResponse({ + docs: [ + { + title: "Harry Potter et le Prince de sang-mêlé", + author_name: ["J. K. Rowling"], + first_publish_year: 2005, + publisher: ["Gallimard jeunesse"], + cover_edition_key: "OL24333986M", + isbn: ["9782070612383"] + } + ] + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await new OpenLibraryProvider().searchByMetadata( + { title: "Harry Potter et le Prince de Sang Mele", author: "J. K. Rowling", year: "2005" }, + config + ); + const url = new URL(String((fetchMock.mock.calls[0] as unknown[])[0])); + + expect(url.searchParams.get("title")).toBe("Harry Potter et le Prince de Sang Mele"); + expect(url.searchParams.get("author")).toBe("J. K. Rowling"); + expect(result[0]).toMatchObject({ + title: "Harry Potter et le Prince de sang-mêlé", + sourceId: "OL24333986M", + author: "J. K. Rowling", + publishedDate: "2005" + }); + }); + + it("looks up OpenLibrary edition details from a search result source id", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + title: "Harry Potter et le prince de sang-mele", + authors: [{ key: "/authors/OL23919A" }], + languages: [{ key: "/languages/fre" }], + publishers: ["Gallimard jeunesse"], + publish_date: "2005", + isbn_13: ["9782070612383"], + description: { value: "Sixième année à Poudlard." } + }) + ) + .mockResolvedValueOnce(jsonResponse({ name: "J. K. Rowling" })); + vi.stubGlobal("fetch", fetchMock); + + const result = await new OpenLibraryProvider().lookup({ ...lookup, sourceId: "OL24333986M", identifiers: { isbn10: null, isbn13: null, candidates: [] } }, config); + + expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toBe("https://openlibrary.org/books/OL24333986M.json"); + expect(result).toMatchObject({ + title: "Harry Potter et le prince de sang-mele", + author: "J. K. Rowling", + isbn: "9782070612383", + description: "Sixième année à Poudlard." + }); + }); + + it("parses BnF SRU UNIMARC records returned for ISBN lookup", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + textResponse(` + + + + 9782070612376 + fre + + Harry Potter et la chambre des secrets + J. K. Rowling + + + Gallimard jeunesse + DL 2007 + + Résumé BnF. + + + `) + ) + ); + + const result = await new BnfProvider().lookup(lookup, { ...config, provider: "bnf" }); + + expect(result).toMatchObject({ + title: "Harry Potter et la chambre des secrets", + author: "J. K. Rowling", + publisher: "Gallimard jeunesse", + publishedDate: "2007", + isbn: "9782070612376" + }); + }); + + it("prefers BnF title search records with a valid book ISBN over non-book EAN records", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + textResponse(` + + + + + 5030931067112 + Harry Potter et le prince de sang-mêléElectronic arts + + + + + 274419736X + Harry Potter et le prince de sang-mêléJ. K. Rowling + + + + `) + ) + ); + + const result = await new BnfProvider().searchByMetadata( + { title: "Harry Potter et le prince de sang mele", author: null }, + { ...config, provider: "bnf" } + ); + + expect(result[0]).toMatchObject({ + author: "J. K. Rowling", + isbn: "274419736X" + }); + expect(result[1]?.isbn).toBeNull(); + }); +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function textResponse(body: string, status = 200): Response { + return new Response(body, { status, headers: { "content-type": "application/xml" } }); +} diff --git a/apps/api/src/metadata/metadata.module.ts b/apps/api/src/metadata/metadata.module.ts new file mode 100644 index 0000000..5f7145b --- /dev/null +++ b/apps/api/src/metadata/metadata.module.ts @@ -0,0 +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, 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 new file mode 100644 index 0000000..9a7823d --- /dev/null +++ b/apps/api/src/metadata/metadata.service.test.ts @@ -0,0 +1,923 @@ +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"; + +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; + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("MetadataService", () => { + it.runIf(canLoadBetterSqlite())("backfills description from provider lookup after a metadata search hit yields an ISBN", async () => { + const database = createDatabase(); + const localProvider = providerStub("local"); + const openLibrarySearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise>(async () => [ + { + title: "Harry Potter et le Prince de sang-mêlé", + author: "J. K. Rowling", + isbn: "9782070612383", + publishedDate: "2005" + } + ]); + const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise>(async (lookup) => { + if (lookup.identifiers.isbn13 !== "9782070612383") return null; + return { + title: "Harry Potter et le Prince de sang-mêlé", + author: "J. K. Rowling", + isbn: "9782070612383", + publishedDate: "2005", + description: "Harry Potter découvre l'héritage du Prince de Sang-Mêlé." + }; + }); + const openLibraryProvider: MetadataProvider = { + id: "openlibrary", + searchByMetadata: openLibrarySearch, + lookup: openLibraryLookup + }; + const service = new MetadataService( + database, + localProvider as never, + openLibraryProvider as never, + providerStub("googlebooks") 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", + author: "J. K. Rowling", + description: null, + isbn: null, + language: null, + publisher: null, + publishedDate: null, + coverPath: null + }; + + const result = await service.enrichMetadata(localMetadata, "/library/HP/Harry Potter et le Prince de Sang Mele.epub", { + remote: true + }); + + expect(openLibrarySearch).toHaveBeenCalledOnce(); + expect(openLibraryLookup).toHaveBeenCalledOnce(); + expect(openLibraryLookup.mock.calls[0]?.[0].identifiers.isbn13).toBe("9782070612383"); + expect(result).toMatchObject({ + 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é." + }); + + 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"); + const openLibrarySearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise>(async () => [ + { + title: "Harry Potter et le prince de sang-mele", + sourceId: "OL24333986M", + publishedDate: "2005" + } + ]); + const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise>(async (lookup) => { + if (lookup.sourceId !== "OL24333986M") return null; + return { + title: "Harry Potter et le prince de sang-mêlé", + author: "J. K. Rowling", + isbn: "9782070612383", + publishedDate: "2005", + description: "Sixième année à Poudlard." + }; + }); + const openLibraryProvider: MetadataProvider = { + id: "openlibrary", + searchByMetadata: openLibrarySearch, + lookup: openLibraryLookup + }; + const service = new MetadataService( + database, + localProvider as never, + openLibraryProvider as never, + providerStub("googlebooks") 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", + author: null, + description: null, + isbn: null, + language: null, + publisher: null, + publishedDate: null, + coverPath: null + }; + + const result = await service.enrichMetadata(localMetadata, "/library/Harry Potter et le prince de sang mele.epub", { + remote: true + }); + + expect(openLibrarySearch).toHaveBeenCalledOnce(); + expect(openLibraryLookup).toHaveBeenCalledOnce(); + expect(openLibraryLookup.mock.calls[0]?.[0].sourceId).toBe("OL24333986M"); + expect(result).toMatchObject({ + title: "Harry Potter et le prince de sang mele", + author: "J. K. Rowling", + isbn: "9782070612383", + isbn13: "9782070612383", + description: "Sixième année à Poudlard." + }); + + database.onModuleDestroy(); + }); + + 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 = { + id: "openlibrary", + lookup: async () => null, + searchByMetadata: async () => [ + { + title: "Daredevil", + author: "Rosemary Carter", + isbn: "9780373105601", + publisher: "Harlequin Books", + publishedDate: "1982" + } + ] + }; + const bnfProvider: MetadataProvider = { + id: "bnf", + lookup: async () => null, + searchByMetadata: async () => [ + { + title: "Daredevil", + author: "scénario, Roy Thomas, Gary Friedrich", + isbn: "9782809476255", + description: "Daredevil affronte l'Homme aux échasses.", + language: "fre", + publisher: "Panini comics", + publishedDate: "2019" + } + ] + }; + const service = new MetadataService( + database, + providerStub("local") as never, + openLibraryProvider as never, + providerStub("googlebooks") as never, + bnfProvider as never, + providerStub("mangadex") as never, + providerStub("comicvine") as never + ); + const localMetadata: BookMetadata = { + title: "Daredevil", + author: null, + description: null, + isbn: null, + language: null, + publisher: null, + publishedDate: null, + coverPath: null + }; + + const result = await service.enrichMetadata(localMetadata, "/library/Daredevil.cbz", { remote: true }); + + expect(result).toMatchObject({ + title: "Daredevil", + 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 { + const dir = mkdtempSync(join(tmpdir(), "readabook-metadata-service-")); + tempDirs.push(dir); + process.env.DATABASE_PATH = join(dir, "readabook.sqlite"); + process.env.STORAGE_DIR = join(dir, "storage"); + return new DatabaseService(); +} + +function providerStub(id: MetadataProvider["id"]): MetadataProvider { + return { + id, + lookup: async () => null, + searchByMetadata: async () => [] + }; +} + +function canLoadBetterSqlite(): boolean { + try { + const database = createDatabase(); + database.onModuleDestroy(); + return true; + } catch { + return false; + } +} diff --git a/apps/api/src/metadata/metadata.service.ts b/apps/api/src/metadata/metadata.service.ts new file mode 100644 index 0000000..005ca2a --- /dev/null +++ b/apps/api/src/metadata/metadata.service.ts @@ -0,0 +1,627 @@ +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, 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 { 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 { ScoredMetadataMatch, ScoreMetadataMatch } from "./use-cases/score-metadata-match.js"; + +type ProviderCandidate = ScoredMetadataMatch & { + provider: MetadataProviderId; + priority: number; +}; + +@Injectable() +export class MetadataService { + private readonly extractIdentifiers = new ExtractIdentifiers(); + private readonly extractLocalMetadataHints = new ExtractLocalMetadataHints(); + private readonly scoreMetadataMatch = new ScoreMetadataMatch(); + private readonly resolveProviderChain: ResolveProviderChain; + + constructor( + private readonly database: DatabaseService, + local: LocalMetadataProvider, + openLibrary: OpenLibraryProvider, + googleBooks: GoogleBooksProvider, + bnf: BnfProvider, + mangaDex: MangaDexProvider, + comicVine: ComicVineProvider + ) { + this.resolveProviderChain = new ResolveProviderChain([local, openLibrary, googleBooks, bnf, mangaDex, comicVine]); + } + + getSourcesConfig(): MetadataSourcesConfigDto { + const settings = this.getAutomationRow(); + return { + isbnPriorityEnabled: Boolean(settings.isbnPriorityEnabled), + sources: this.getProviderConfigs().map((source) => ({ + provider: source.provider, + enabled: source.provider === "local" ? true : source.enabled, + priority: source.priority, + hasApiKey: Boolean(source.apiKey) + })) + }; + } + + updateSourcesConfig(input: UpdateMetadataSourcesConfigDto): MetadataSourcesConfigDto { + const now = this.database.now(); + if (input.isbnPriorityEnabled !== undefined) { + this.database.db + .update(automationSettings) + .set({ isbnPriorityEnabled: input.isbnPriorityEnabled, updatedAt: now }) + .where(eq(automationSettings.id, 1)) + .run(); + } + for (const source of input.sources ?? []) { + if ((source.provider as string) === "local") { + throw new BadRequestException("Local metadata source is always active and cannot be updated"); + } + const values: Partial = { + enabled: source.enabled, + priority: source.priority, + updatedAt: now + }; + if (source.apiKey !== undefined) values.apiKey = source.apiKey; + this.database.db.update(metadataSourceConfig).set(values).where(eq(metadataSourceConfig.provider, source.provider)).run(); + } + return this.getSourcesConfig(); + } + + async enrichMetadata( + localMetadata: BookMetadata, + filePath: string, + options: { remote: boolean } + ): 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"); + 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 = hasIsbn + ? await provider.lookup( + { + title: local.title, + author: local.author, + filePath, + sourceId: null, + identifiers, + local + }, + config + ) + : null; + if (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) 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; + let providerMatch = best.match; + + 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 (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), + 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 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({ + 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: 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)) + .returning() + .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() + .from(metadataSourceConfig) + .all() + .map((row) => ({ + provider: row.provider, + enabled: row.provider === "local" ? true : row.enabled, + priority: row.provider === "local" ? 0 : row.priority, + apiKey: row.apiKey + })); + } + + private getAutomationRow(): typeof automationSettings.$inferSelect { + return this.database.db.select().from(automationSettings).where(eq(automationSettings.id, 1)).get()!; + } + + private async lookupSearchMatchDetails( + provider: MetadataProvider, + config: MetadataProviderConfig, + filePath: string, + identifiers: BookIdentifiers, + local: LocalMetadataHints, + match: MetadataMatch + ): Promise { + const derivedIdentifiers = { + ...identifiers, + isbn13: identifiers.isbn13 ?? (match.isbn ? toIsbn13(match.isbn) : null), + isbn10: identifiers.isbn10 ?? match.isbn ?? null, + candidates: [...new Set([...identifiers.candidates, ...(match.isbn ? [match.isbn] : [])])] + }; + const hasNewIdentifier = derivedIdentifiers.isbn13 !== identifiers.isbn13 || derivedIdentifiers.isbn10 !== identifiers.isbn10; + const hasLookupTarget = hasNewIdentifier || Boolean(match.sourceId); + if (!hasLookupTarget) return null; + + return provider.lookup( + { + title: match.title ?? local.title, + author: match.author ?? local.author, + filePath, + sourceId: match.sourceId, + identifiers: derivedIdentifiers, + local + }, + 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 mergeMetadataMatch(current: MetadataMatch, next: MetadataMatch | null): MetadataMatch { + if (!next) return current; + return { + 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 new file mode 100644 index 0000000..f2200da --- /dev/null +++ b/apps/api/src/metadata/metadata.types.ts @@ -0,0 +1,65 @@ +import { BookMetadata } from "../scanner/metadata.js"; + +export type MetadataProviderId = "local" | "openlibrary" | "googlebooks" | "bnf" | "mangadex" | "comicvine"; + +export type BookIdentifiers = { + isbn10: string | null; + isbn13: string | null; + candidates: string[]; +}; + +export type MetadataLookup = { + title: string; + author: string | null; + filePath: string; + sourceId?: string | null; + identifiers: BookIdentifiers; + local: LocalMetadataHints; +}; + +export type LocalMetadataHints = { + title: string; + author: string | null; + year: string | null; + isbn: string | null; + fileTitle: string; + raw: { + title: string; + author: string | null; + publishedDate: string | null; + fileName: string; + }; +}; + +export type MetadataSearchQuery = { + title: string; + author: string | null; + year?: string | null; + isbn?: string | null; +}; + +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; + priority: number; + apiKey: string | null; +}; + +export interface MetadataProvider { + readonly id: MetadataProviderId; + lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise; + searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise; +} 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/resolve-provider-chain.test.ts b/apps/api/src/metadata/resolve-provider-chain.test.ts new file mode 100644 index 0000000..8e02209 --- /dev/null +++ b/apps/api/src/metadata/resolve-provider-chain.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { MetadataProvider } from "./metadata.types.js"; +import { ResolveProviderChain } from "./use-cases/resolve-provider-chain.js"; + +const provider = (id: MetadataProvider["id"]): MetadataProvider => ({ + id, + lookup: async () => null, + searchByMetadata: async () => [] +}); + +describe("ResolveProviderChain", () => { + it("keeps local active and orders enabled remote providers by priority", () => { + const chain = new ResolveProviderChain([provider("googlebooks"), provider("local"), provider("bnf")]).resolve([ + { provider: "local", enabled: false, priority: 99, apiKey: null }, + { provider: "googlebooks", enabled: true, priority: 2, apiKey: null }, + { provider: "bnf", enabled: true, priority: 1, apiKey: null } + ]); + + expect(chain.map((entry) => entry.provider.id)).toEqual(["bnf", "googlebooks", "local"]); + }); +}); diff --git a/apps/api/src/metadata/use-cases/extract-identifiers.ts b/apps/api/src/metadata/use-cases/extract-identifiers.ts new file mode 100644 index 0000000..2f046af --- /dev/null +++ b/apps/api/src/metadata/use-cases/extract-identifiers.ts @@ -0,0 +1,114 @@ +import { readFileSync } from "node:fs"; +import { basename, extname } from "node:path"; +import AdmZip from "adm-zip"; +import { XMLParser } from "fast-xml-parser"; + +export type ExtractedIdentifiers = { + isbn10: string | null; + isbn13: string | null; + candidates: string[]; +}; + +const xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + textNodeName: "#text" +}); + +export class ExtractIdentifiers { + fromMetadataAndFile(metadata: { isbn?: string | null }, filePath: string): ExtractedIdentifiers { + const candidates = new Set(); + for (const value of [metadata.isbn, basename(filePath, extname(filePath))]) { + for (const isbn of findIsbns(String(value ?? ""))) candidates.add(isbn); + } + + const extension = extname(filePath).toLowerCase(); + if (extension === ".epub") { + for (const isbn of findIsbns(readLimitedEpubText(filePath))) candidates.add(isbn); + } + if (extension === ".pdf") { + const buffer = readFileSync(filePath); + const head = buffer.subarray(0, Math.min(buffer.length, 256 * 1024)).toString("latin1"); + for (const isbn of findIsbns(head)) candidates.add(isbn); + } + + return normalizeCandidates([...candidates]); + } +} + +export function normalizeIsbn(value: string): string | null { + const compact = value.replace(/[^0-9X]/gi, "").toUpperCase(); + if (compact.length === 10 && isValidIsbn10(compact)) return compact; + if (compact.length === 13 && /^97[89]/.test(compact) && isValidIsbn13(compact)) return compact; + return null; +} + +export function toIsbn13(value: string): string | null { + const isbn = normalizeIsbn(value); + if (!isbn) return null; + if (isbn.length === 13) return isbn; + const stem = `978${isbn.slice(0, 9)}`; + let sum = 0; + for (let index = 0; index < stem.length; index += 1) { + sum += Number(stem[index]) * (index % 2 === 0 ? 1 : 3); + } + return `${stem}${(10 - (sum % 10)) % 10}`; +} + +function normalizeCandidates(values: string[]): ExtractedIdentifiers { + const normalized = [...new Set(values.map(normalizeIsbn).filter((value): value is string => Boolean(value)))]; + const isbn13 = normalized.map(toIsbn13).find((value): value is string => Boolean(value)) ?? null; + const isbn10 = normalized.find((value) => value.length === 10) ?? null; + return { isbn10, isbn13, candidates: normalized }; +} + +function findIsbns(text: string): string[] { + const matches = text.match(/(?:ISBN(?:-1[03])?:?\s*)?(?:97[89][-\s]?)?(?:\d[-\s]?){9,12}[\dX]/gi) ?? []; + return matches.map((match) => match.replace(/^ISBN(?:-1[03])?:?\s*/i, "")); +} + +function isValidIsbn10(value: string): boolean { + let sum = 0; + for (let index = 0; index < 10; index += 1) { + const char = value[index]; + const digit = char === "X" && index === 9 ? 10 : Number(char); + if (!Number.isInteger(digit)) return false; + sum += digit * (10 - index); + } + return sum % 11 === 0; +} + +function isValidIsbn13(value: string): boolean { + let sum = 0; + for (let index = 0; index < 13; index += 1) { + const digit = Number(value[index]); + if (!Number.isInteger(digit)) return false; + sum += digit * (index % 2 === 0 ? 1 : 3); + } + return sum % 10 === 0; +} + +function readLimitedEpubText(filePath: string): string { + try { + const zip = new AdmZip(filePath); + const fragments: string[] = [zip.readAsText("META-INF/container.xml")]; + for (const entry of zip.getEntries()) { + if (fragments.join("").length > 256 * 1024) break; + if (!entry.isDirectory && /\.(opf|xhtml|html|htm|xml)$/i.test(entry.entryName)) { + fragments.push(stripXml(zip.readAsText(entry))); + } + } + return fragments.join("\n"); + } catch { + return ""; + } +} + +function stripXml(value: string): string { + try { + const parsed = xmlParser.parse(value); + return JSON.stringify(parsed).slice(0, 256 * 1024); + } catch { + return value.slice(0, 256 * 1024); + } +} diff --git a/apps/api/src/metadata/use-cases/extract-local-metadata-hints.ts b/apps/api/src/metadata/use-cases/extract-local-metadata-hints.ts new file mode 100644 index 0000000..4bf80fd --- /dev/null +++ b/apps/api/src/metadata/use-cases/extract-local-metadata-hints.ts @@ -0,0 +1,66 @@ +import { basename, extname } from "node:path"; +import { BookMetadata } from "../../scanner/metadata.js"; +import { LocalMetadataHints } from "../metadata.types.js"; + +export class ExtractLocalMetadataHints { + fromMetadataAndFile(metadata: BookMetadata, filePath: string): LocalMetadataHints { + const fileName = basename(filePath, extname(filePath)); + const parsed = parseFileName(fileName); + const title = cleanTitle(metadata.title) || parsed.title || fileName; + const author = cleanValue(metadata.author) ?? parsed.author; + const year = yearFrom(metadata.publishedDate) ?? parsed.year; + + return { + title, + author, + year, + isbn: cleanValue(metadata.isbn), + fileTitle: parsed.title ?? fileName, + raw: { + title: metadata.title, + author: metadata.author, + publishedDate: metadata.publishedDate, + fileName + } + }; + } +} + +function parseFileName(fileName: string): { title: string | null; author: string | null; year: string | null } { + let value = fileName.replace(/[_]+/g, " ").replace(/\s+/g, " ").trim(); + const year = yearFrom(value); + if (year) value = value.replace(new RegExp(`\\b${year}\\b`), " "); + + const parenthetical = [...value.matchAll(/\(([^()]{2,120})\)/g)].map((match) => match[1].trim()); + const authorFromParentheses = parenthetical.find((item) => looksLikeAuthor(item)) ?? null; + value = value.replace(/\([^()]*\)/g, " "); + + const split = value.match(/^(.+?)\s+-\s+(.+)$/); + const title = cleanTitle(split?.[1] ?? value); + const author = cleanValue(split?.[2]) ?? authorFromParentheses; + return { title, author, year }; +} + +function cleanTitle(value: string | null): string | null { + if (!value) return null; + const cleaned = value + .replace(/\[[^\]]*\]/g, " ") + .replace(/\b(epub|pdf|retail|ebook|scan)\b/gi, " ") + .replace(/\s+/g, " ") + .trim(); + return cleaned || null; +} + +function cleanValue(value: string | null | undefined): string | null { + if (!value) return null; + const cleaned = value.replace(/\s+/g, " ").trim(); + return cleaned || null; +} + +function yearFrom(value: string | null): string | null { + return value?.match(/\b(1[5-9]\d{2}|20\d{2})\b/)?.[1] ?? null; +} + +function looksLikeAuthor(value: string): boolean { + return /[A-Za-zÀ-ÖØ-öø-ÿ]/.test(value) && (value.includes(".") || value.includes(" ") || /^[A-Z][a-z]+$/.test(value)); +} 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/resolve-provider-chain.ts b/apps/api/src/metadata/use-cases/resolve-provider-chain.ts new file mode 100644 index 0000000..0314ae2 --- /dev/null +++ b/apps/api/src/metadata/use-cases/resolve-provider-chain.ts @@ -0,0 +1,14 @@ +import { MetadataProvider, MetadataProviderConfig } from "../metadata.types.js"; + +export class ResolveProviderChain { + constructor(private readonly providers: MetadataProvider[]) {} + + resolve(configs: MetadataProviderConfig[]): Array<{ provider: MetadataProvider; config: MetadataProviderConfig }> { + const configById = new Map(configs.map((config) => [config.provider, config])); + return this.providers + .map((provider) => ({ provider, config: configById.get(provider.id) })) + .filter((entry): entry is { provider: MetadataProvider; config: MetadataProviderConfig } => Boolean(entry.config)) + .filter((entry) => entry.config.provider === "local" || entry.config.enabled) + .sort((left, right) => left.config.priority - right.config.priority); + } +} diff --git a/apps/api/src/metadata/use-cases/score-metadata-match.ts b/apps/api/src/metadata/use-cases/score-metadata-match.ts new file mode 100644 index 0000000..84e16b3 --- /dev/null +++ b/apps/api/src/metadata/use-cases/score-metadata-match.ts @@ -0,0 +1,127 @@ +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 { + return this.details(query, match).score; + } + + 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) => this.details(query, match)) + .sort((left, right) => compareScoredMatches(query, left, right)); + const best = scored[0]; + return best && best.score >= minimumScore ? best : null; + } +} + +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(/\s+/g, " ") + .trim(); +} + +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/progress/progress.controller.ts b/apps/api/src/progress/progress.controller.ts index 808f877..5f8839e 100644 --- a/apps/api/src/progress/progress.controller.ts +++ b/apps/api/src/progress/progress.controller.ts @@ -17,7 +17,7 @@ export class ProgressController { @Get(":bookId") get(@CurrentUserParam() user: CurrentUser, @Param("bookId") bookId: string) { - return this.progress.get(user.id, Number(bookId)); + return this.progress.find(user.id, Number(bookId)); } @Put(":bookId") diff --git a/apps/api/src/progress/progress.service.ts b/apps/api/src/progress/progress.service.ts index 6e40277..1aa4d57 100644 --- a/apps/api/src/progress/progress.service.ts +++ b/apps/api/src/progress/progress.service.ts @@ -30,6 +30,14 @@ export class ProgressService { } get(userId: number, bookId: number) { + const row = this.find(userId, bookId); + if (!row) { + throw new NotFoundException("Progress not found"); + } + return row; + } + + find(userId: number, bookId: number) { const row = this.database.db .select({ bookId: progress.bookId, @@ -40,10 +48,7 @@ export class ProgressService { .from(progress) .where(sql`${progress.userId} = ${userId} AND ${progress.bookId} = ${bookId}`) .get(); - if (!row) { - throw new NotFoundException("Progress not found"); - } - return row; + return row ?? null; } continueReading(userId: number) { diff --git a/apps/api/src/reader/reader-preferences.controller.ts b/apps/api/src/reader/reader-preferences.controller.ts new file mode 100644 index 0000000..012baeb --- /dev/null +++ b/apps/api/src/reader/reader-preferences.controller.ts @@ -0,0 +1,26 @@ +import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common"; +import { UpdateReaderPreferencesDto, UpdateReaderPreferencesSchema } from "@readabook/shared"; +import { AuthGuard } from "../auth/auth.guard.js"; +import { CurrentUser, CurrentUserParam } from "../auth/current-user.js"; +import { ZodValidationPipe } from "../common/zod-validation.pipe.js"; +import { ReaderPreferencesService } from "./reader-preferences.service.js"; + +@Controller("reader/preferences") +@UseGuards(AuthGuard) +export class ReaderPreferencesController { + constructor(private readonly preferences: ReaderPreferencesService) {} + + @Get(":bookId") + get(@CurrentUserParam() user: CurrentUser, @Param("bookId") bookId: string) { + return this.preferences.find(user.id, Number(bookId)); + } + + @Put(":bookId") + update( + @CurrentUserParam() user: CurrentUser, + @Param("bookId") bookId: string, + @Body(new ZodValidationPipe(UpdateReaderPreferencesSchema)) body: UpdateReaderPreferencesDto + ) { + return this.preferences.upsert(user.id, Number(bookId), body); + } +} diff --git a/apps/api/src/reader/reader-preferences.service.ts b/apps/api/src/reader/reader-preferences.service.ts new file mode 100644 index 0000000..2c3d716 --- /dev/null +++ b/apps/api/src/reader/reader-preferences.service.ts @@ -0,0 +1,47 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { eq, sql } from "drizzle-orm"; +import { UpdateReaderPreferencesDto } from "@readabook/shared"; +import { DatabaseService } from "../database/database.service.js"; +import { books, readerPreferences } from "../database/schema.js"; + +@Injectable() +export class ReaderPreferencesService { + constructor(private readonly database: DatabaseService) {} + + find(userId: number, bookId: number) { + const row = this.database.db + .select({ + mode: readerPreferences.mode, + fit: readerPreferences.fit, + updatedAt: readerPreferences.updatedAt + }) + .from(readerPreferences) + .where(sql`${readerPreferences.userId} = ${userId} AND ${readerPreferences.bookId} = ${bookId}`) + .get(); + return row ?? { mode: "paged", fit: null }; + } + + upsert(userId: number, bookId: number, input: UpdateReaderPreferencesDto) { + const book = this.database.db.select({ id: books.id }).from(books).where(eq(books.id, bookId)).get(); + if (!book) throw new NotFoundException("Book not found"); + + const current = this.find(userId, bookId); + const now = this.database.now(); + const mode = input.mode ?? current.mode; + const fit = input.fit === undefined ? current.fit : input.fit; + + this.database.sqlite + .prepare( + ` + INSERT INTO reader_preferences(user_id, book_id, mode, fit, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, book_id) DO UPDATE SET + mode = excluded.mode, + fit = excluded.fit, + updated_at = excluded.updated_at + ` + ) + .run(userId, bookId, mode, fit, now, now); + return this.find(userId, bookId); + } +} diff --git a/apps/api/src/reader/reader.module.ts b/apps/api/src/reader/reader.module.ts new file mode 100644 index 0000000..b294f8e --- /dev/null +++ b/apps/api/src/reader/reader.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module.js"; +import { DatabaseModule } from "../database/database.module.js"; +import { ReaderPreferencesController } from "./reader-preferences.controller.js"; +import { ReaderPreferencesService } from "./reader-preferences.service.js"; + +@Module({ + imports: [AuthModule, DatabaseModule], + controllers: [ReaderPreferencesController], + providers: [ReaderPreferencesService] +}) +export class ReaderModule {} diff --git a/apps/api/src/scanner/metadata.test.ts b/apps/api/src/scanner/metadata.test.ts index c20554d..c7ca2b4 100644 --- a/apps/api/src/scanner/metadata.test.ts +++ b/apps/api/src/scanner/metadata.test.ts @@ -1,18 +1,56 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import AdmZip from "adm-zip"; +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", () => { + it("falls back to file name and reads simple PDF info fields", async () => { const dir = mkdtempSync(join(tmpdir(), "readabook-")); const file = join(dir, "Example.pdf"); writeFileSync(file, "%PDF-1.4\n1 0 obj << /Title (My Book) /Author (Ada) >> endobj"); - const metadata = extractMetadata(file, dir); + const metadata = await extractMetadata(file, dir); expect(metadata.title).toBe("My Book"); expect(metadata.author).toBe("Ada"); }); }); + +describe("cbz metadata extraction", () => { + it("uses the file name as title and first image as cover", async () => { + const dir = mkdtempSync(join(tmpdir(), "readabook-")); + const file = join(dir, "Comic One.cbz"); + const zip = new AdmZip(); + zip.addFile("002.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9])); + zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9])); + zip.writeZip(file); + + const metadata = await extractMetadata(file, dir); + const pages = listCbzImageEntries(file); + + expect(metadata.title).toBe("Comic One"); + expect(metadata.coverPath).toMatch(/covers\/[a-f0-9]+\.jpg$/); + expect(pages.map((page) => page.name)).toEqual(["001.jpg", "002.jpg"]); + }); +}); + +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 c954722..b5bd46a 100644 --- a/apps/api/src/scanner/metadata.ts +++ b/apps/api/src/scanner/metadata.ts @@ -3,6 +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, 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; @@ -21,11 +24,17 @@ const xmlParser = new XMLParser({ textNodeName: "#text" }); -export function extractMetadata(filePath: string, storageDir: string): BookMetadata { +export async function extractMetadata(filePath: string, storageDir: string): Promise { const extension = extname(filePath).toLowerCase(); if (extension === ".epub") { return extractEpubMetadata(filePath, storageDir); } + if (extension === ".cbz") { + return extractCbzMetadata(filePath, storageDir); + } + if (extension === ".cbr") { + return extractCbrMetadata(filePath, storageDir); + } return extractPdfMetadata(filePath); } @@ -56,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 }; } @@ -78,6 +87,26 @@ function extractPdfMetadata(filePath: string): BookMetadata { }; } +function extractCbzMetadata(filePath: string, storageDir: string): BookMetadata { + const zip = new AdmZip(filePath); + const firstPage = listCbzImageEntries(filePath)[0]; + const coverPath = extractCover(zip, firstPage.entryName, filePath, storageDir); + return { + ...fallbackMetadata(filePath), + coverPath + }; +} + +async function extractCbrMetadata(filePath: string, storageDir: string): Promise { + 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 + }; +} + function fallbackMetadata(filePath: string): BookMetadata { return { title: basename(filePath, extname(filePath)), @@ -139,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.module.ts b/apps/api/src/scanner/scanner.module.ts index 5c63116..3970304 100644 --- a/apps/api/src/scanner/scanner.module.ts +++ b/apps/api/src/scanner/scanner.module.ts @@ -1,12 +1,12 @@ import { Module } from "@nestjs/common"; import { DatabaseModule } from "../database/database.module.js"; import { JobsModule } from "../jobs/jobs.module.js"; -import { OpenLibraryService } from "./open-library.service.js"; +import { MetadataModule } from "../metadata/metadata.module.js"; import { ScannerService } from "./scanner.service.js"; @Module({ - imports: [DatabaseModule, JobsModule], - providers: [ScannerService, OpenLibraryService], + imports: [DatabaseModule, JobsModule, MetadataModule], + providers: [ScannerService], exports: [ScannerService] }) export class ScannerModule {} diff --git a/apps/api/src/scanner/scanner.service.test.ts b/apps/api/src/scanner/scanner.service.test.ts new file mode 100644 index 0000000..b23b2b8 --- /dev/null +++ b/apps/api/src/scanner/scanner.service.test.ts @@ -0,0 +1,252 @@ +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", () => { + const detail = scanDigest( + 19, + 0, + [ + { filePath: "/library/broken.cbr", error: "x".repeat(300) }, + { filePath: "/library/broken.epub", error: "Invalid EPUB" } + ] + ); + + expect(detail).toContain("Scanned 19 file(s)"); + expect(detail).toContain("2 incomplete file(s)"); + 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 98af707..33382b6 100644 --- a/apps/api/src/scanner/scanner.service.ts +++ b/apps/api/src/scanner/scanner.service.ts @@ -1,19 +1,21 @@ import { Injectable, NotFoundException } from "@nestjs/common"; -import { readdirSync, statSync } from "node:fs"; -import { extname, join } from "node:path"; -import { eq } from "drizzle-orm"; +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 { 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"; -import { OpenLibraryService } from "./open-library.service.js"; @Injectable() export class ScannerService { constructor( private readonly database: DatabaseService, private readonly jobs: JobsService, - private readonly openLibrary: OpenLibraryService + private readonly metadata: MetadataService ) {} enqueueLibraryScan(libraryId: number) { @@ -28,51 +30,271 @@ export class ScannerService { return job; } + enqueueAllLibrariesScan(detail = "Scanning all enabled libraries") { + const job = this.jobs.create("library-scan-all", detail); + setImmediate(() => { + void this.scanAllLibraries(job.id).catch((error) => this.jobs.markFailed(job.id, error)); + }); + return job; + } + + enqueueMetadataEnrichment(detail = "Enriching existing books") { + const job = this.jobs.create("metadata-enrich", detail); + setImmediate(() => { + void this.enrichExistingBooks(job.id).catch((error) => this.jobs.markFailed(job.id, error)); + }); + return job; + } + private async scanLibrary(jobId: number, library: typeof libraries.$inferSelect): Promise { this.jobs.markRunning(jobId, `Scanning ${library.path}`); let count = 0; + const failures: ScanFailure[] = []; + const seen = new Set(); for (const filePath of walkBooks(library.path)) { - await this.ingestFile(library.id, filePath); - count += 1; + seen.add(filePath); + try { + await this.ingestFile(library.id, filePath); + count += 1; + } catch (error) { + failures.push({ filePath, error: errorMessage(error) }); + this.ingestIncompleteFile(library.id, filePath); + } } - this.jobs.markSucceeded(jobId, `Scanned ${count} file(s)`); + const removed = this.removeMissingBooks(library.id, seen); + this.jobs.markSucceeded(jobId, scanDigest(count, removed, failures)); + } + + private async scanAllLibraries(jobId: number): Promise { + this.jobs.markRunning(jobId, "Scanning all enabled libraries"); + const enabledLibraries = this.database.db.select().from(libraries).where(eq(libraries.enabled, true)).all(); + let scanned = 0; + const failures: ScanFailure[] = []; + for (const library of enabledLibraries) { + for (const filePath of walkBooks(library.path)) { + try { + await this.ingestFile(library.id, filePath); + scanned += 1; + } catch (error) { + failures.push({ filePath, error: errorMessage(error) }); + this.ingestIncompleteFile(library.id, filePath); + } + } + } + this.jobs.markSucceeded(jobId, scanDigest(scanned, 0, failures, `across ${enabledLibraries.length} library/libraries`)); + } + + private async enrichExistingBooks(jobId: number): Promise { + 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) { + 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, enrichmentDigest(count, failures)); } private async ingestFile(libraryId: number, filePath: string): Promise { const stats = statSync(filePath); - let metadata = extractMetadata(filePath, this.database.config.storageDir); - if (this.database.config.openLibraryEnabled) { - try { - metadata = { ...metadata, ...(await this.openLibrary.enrich(metadata)) }; - } catch { - // Remote enrichment is opportunistic; local ingestion must stay deterministic. - } + 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: "epub" | "pdf" = extname(filePath).toLowerCase() === ".epub" ? "epub" : "pdf"; - const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get(); + const format = bookFormatFromPath(filePath); + 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, isbn: metadata.isbn, + isbn13: metadata.isbn13, + identifiersJson: metadata.identifiersJson, + localMetadataJson: metadata.localMetadataJson, 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().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, + isbn: null, + isbn13: null, + identifiersJson: JSON.stringify({ candidates: [], isbn10: null, isbn13: null }), + localMetadataJson: JSON.stringify({ + title: basename(filePath, extname(filePath)), + author: null, + year: null, + isbn: null, + fileTitle: basename(filePath, extname(filePath)), + raw: { + title: basename(filePath, extname(filePath)), + author: null, + publishedDate: null, + fileName: basename(filePath, extname(filePath)) + } + }), + 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 + }; + + 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 { + const existing = this.database.db.select({ id: books.id, filePath: books.filePath }).from(books).where(eq(books.libraryId, libraryId)).all(); + const missing = existing.filter((book) => !seen.has(book.filePath) && !existsSync(book.filePath)); + if (!missing.length) return 0; + this.database.db.delete(books).where(inArray(books.id, missing.map((book) => book.id))).run(); + return missing.length; + } + + private shouldAutoEnrichNewBooks(): boolean { + return Boolean( + this.database.db + .select({ autoEnrichNewBooks: automationSettings.autoEnrichNewBooks }) + .from(automationSettings) + .where(eq(automationSettings.id, 1)) + .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 = { + filePath: string; + error: string; +}; + +export function scanDigest(scanned: number, removed: number, failures: ScanFailure[], suffix?: string): string { + const base = suffix ? `Scanned ${scanned} file(s) ${suffix}` : `Scanned ${scanned} file(s), removed ${removed} missing 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 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)); +} + +function truncate(value: string): string { + return value.length > 120 ? `${value.slice(0, 117)}...` : value; } function* walkBooks(root: string): Generator { @@ -84,8 +306,62 @@ function* walkBooks(root: string): Generator { } if (!entry.isFile()) continue; const extension = extname(entry.name).toLowerCase(); - if (extension === ".epub" || extension === ".pdf") { + if (extension === ".epub" || extension === ".pdf" || extension === ".cbz" || extension === ".cbr") { yield path; } } } + +function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" | "cbr" { + const extension = extname(filePath).toLowerCase(); + if (extension === ".epub") return "epub"; + if (extension === ".cbz") return "cbz"; + if (extension === ".cbr") return "cbr"; + return "pdf"; +} + +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 ef87ba5..8b917b9 100644 --- a/apps/web/nginx/default.conf +++ b/apps/web/nginx/default.conf @@ -4,26 +4,50 @@ server { root /usr/share/nginx/html; index index.html; - location /auth/ { - proxy_pass http://api:3000/auth/; + location = /sw.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files /sw.js =404; + } + + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files /index.html =404; + } + + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location ^~ /auth { + proxy_pass http://api:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } - location /admin/ { - proxy_pass http://api:3000/admin/; + location ^~ /admin { + if ($http_accept ~* "text/html") { + rewrite ^ /index.html last; + } + proxy_pass http://api:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } - location /books/ { - proxy_pass http://api:3000/books/; + location ^~ /books { + 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/progress/; + 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; proxy_set_header X-Real-IP $remote_addr; } diff --git a/apps/web/package.json b/apps/web/package.json index a8b62e3..82154c1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "vite": "^8.2.2" }, "devDependencies": { + "@napi-rs/canvas": "1.0.7", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.3", "typescript": "^5.7.3", diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index 6d7910e..ef6df93 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -1,5 +1,5 @@ -const CACHE_NAME = "readabook-shell-v1"; -const SHELL = ["/", "/home", "/manifest.webmanifest", "/icons/readabook.svg"]; +const CACHE_NAME = "readabook-shell-v2"; +const SHELL = ["/", "/index.html", "/manifest.webmanifest", "/icons/readabook.svg"]; self.addEventListener("install", (event) => { event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL))); @@ -18,5 +18,19 @@ self.addEventListener("fetch", (event) => { if (event.request.method !== "GET" || ["/auth", "/admin", "/books", "/progress"].some((path) => url.pathname.startsWith(path))) { return; } - event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((hit) => hit || caches.match("/")))); + if (event.request.mode === "navigate") { + event.respondWith( + fetch(event.request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put("/index.html", copy)); + return response; + }) + .catch(() => caches.match("/index.html").then((hit) => hit || caches.match("/"))) + ); + return; + } + event.respondWith( + caches.match(event.request).then((hit) => hit || fetch(event.request)) + ); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 70be6aa..b212d43 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,7 +1,9 @@ import { useEffect, useState } from "react"; import type { Session } from "./api/types"; import { api } from "./api/client"; +import { isPrivateRoute } from "./auth/routing"; import { AppShell } from "./layout/AppShell"; +import { AdminAutomationPage } from "./pages/AdminAutomationPage"; import { AdminPage } from "./pages/AdminPage"; import { BookPage } from "./pages/BookPage"; import { HomePage } from "./pages/HomePage"; @@ -10,8 +12,9 @@ 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 { parseRoute, type Route } from "./router"; +import { navigate, parseRoute, type Route } from "./router"; function renderRoute(route: Route, session: Session, refreshSession: () => Promise) { if (route.name === "login") return ; @@ -22,6 +25,8 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi ) : route.name === "library" ? ( + ) : route.name === "catalogSeries" ? ( + ) : route.name === "book" ? ( ) : route.name === "reader" ? ( @@ -30,19 +35,27 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi ) : route.name === "me" ? ( + ) : route.name === "admin" && route.section === "automation" ? ( + ) : ( ); - return {content}; + return ( + + {content} + + ); } export function App() { const [route, setRoute] = useState(parseRoute()); const [session, setSession] = useState({ user: null, degraded: false }); + const [sessionChecked, setSessionChecked] = useState(false); async function refreshSession() { setSession(await api.session()); + setSessionChecked(true); } useEffect(() => { @@ -55,5 +68,35 @@ export function App() { return () => window.removeEventListener("popstate", listener); }, []); + useEffect(() => { + const listener = () => { + setSession({ user: null, degraded: false }); + setSessionChecked(true); + if (isPrivateRoute(parseRoute())) navigate("/login"); + }; + window.addEventListener("readabook:session-expired", listener); + return () => window.removeEventListener("readabook:session-expired", listener); + }, []); + + useEffect(() => { + if (sessionChecked && !session.user && isPrivateRoute(route)) navigate("/login"); + }, [route, session.user, sessionChecked]); + + if (!sessionChecked && isPrivateRoute(route)) { + return ( +
+
+

Cabinet de curiosites numerique

+

ReadaBook

+ Verification de session. +
+
+ ); + } + + if (sessionChecked && !session.user && isPrivateRoute(route)) { + return ; + } + return renderRoute(route, session, refreshSession); } diff --git a/apps/web/src/api/client.test.ts b/apps/web/src/api/client.test.ts new file mode 100644 index 0000000..2811fbc --- /dev/null +++ b/apps/web/src/api/client.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { api, ApiFallbackError, getApiFallback } from "./client"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("api fallback helpers", () => { + it("extracts typed fallback payloads", () => { + expect(getApiFallback(new ApiFallbackError("offline", ["demo"]))).toEqual(["demo"]); + }); + + it("ignores non fallback errors", () => { + expect(getApiFallback(new Error("boom"))).toBeUndefined(); + }); + + it("does not cap the home catalogue request to the first 50 books", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + vi.stubGlobal("fetch", fetchMock); + + await api.books(); + + expect(String(fetchMock.mock.calls[0][0])).not.toContain("limit=50"); + }); + + it("does not cap search requests to the first 50 books", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + vi.stubGlobal("fetch", fetchMock); + + await api.search("daredevil"); + + expect(String(fetchMock.mock.calls[0][0])).not.toContain("limit=50"); + }); + + it("does not send JSON content-type for bodyless delete requests", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + vi.stubGlobal("fetch", fetchMock); + + await api.deleteLibrary(42); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + const headers = new Headers(init.headers); + expect(init.method).toBe("DELETE"); + expect(headers.has("Content-Type")).toBe(false); + }); + + it("surfaces create library API errors without fallback", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: "Library path does not exist" }), { + status: 400, + statusText: "Bad Request", + headers: { "Content-Type": "application/json" } + }) + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(api.createLibrary({ name: "Books", path: "/missing", enabled: true })).rejects.toThrow("Library path does not exist"); + }); + + it("does not fallback when scan enqueue fails", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("offline")); + vi.stubGlobal("fetch", fetchMock); + + await expect(api.scanLibrary(42)).rejects.toThrow("offline"); + }); + + it("keeps reader preferences locally when the backend contract is absent", async () => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + clear: () => storage.clear() + }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 404, statusText: "Not Found" })); + vi.stubGlobal("fetch", fetchMock); + + await expect(api.readerPreferences(8)).resolves.toEqual({ mode: "horizontal", fit: "page" }); + await expect(api.saveReaderPreferences(8, { mode: "vertical", fit: "width" })).resolves.toEqual({ mode: "vertical", fit: "width" }); + expect(storage.get("readabook:reader-preferences:8")).toBe(JSON.stringify({ mode: "vertical", fit: "width" })); + }); + + it("sends metadata source updates to the admin endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ isbnPriorityEnabled: false, sources: [] }), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + vi.stubGlobal("fetch", fetchMock); + + await api.updateMetadataSources({ + isbnPriorityEnabled: false, + sources: [{ provider: "openlibrary", enabled: true, priority: 1 }] + }); + + expect(fetchMock.mock.calls[0][0]).toBe("/admin/metadata-sources"); + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(init.method).toBe("PUT"); + expect(JSON.parse(init.body as string)).toEqual({ + isbnPriorityEnabled: false, + sources: [{ provider: "openlibrary", enabled: true, priority: 1 }] + }); + }); + + it("sends automation settings to the admin endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + watchLibraries: true, + autoEnrichNewBooks: true, + scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 }, + enrichSchedule: { frequency: "disabled", time: "04:00", dayOfWeek: 1 } + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ) + ); + vi.stubGlobal("fetch", fetchMock); + + await api.updateAutomationSettings({ + watchLibraries: true, + scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 } + }); + + expect(fetchMock.mock.calls[0][0]).toBe("/admin/automation"); + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(init.method).toBe("PUT"); + expect(JSON.parse(init.body as string)).toEqual({ + watchLibraries: true, + scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 } + }); + }); +}); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 25830e2..1fdf1a0 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,19 +1,35 @@ import type { BookDto, BookQueryDto, + AuthStatusDto, + AutomationSettingsDto, BootstrapAdminDto, CreateLibraryDto, JobDto, LibraryDto, LoginDto, + MetadataSourcesConfigDto, ProgressDto, + UpdateAutomationSettingsDto, + UpdateAccountDto, + UpdateMetadataSourcesConfigDto, UpdateProgressDto, UserDto } from "@readabook/shared"; -import { mockBooks, mockContinue, mockJobs, mockLibraries, mockProgress, mockUser } from "./mockData"; -import type { ContinueItem, Session } from "./types"; +import { + mockAutomationSettings, + mockBooks, + mockContinue, + mockJobs, + mockLibraries, + mockMetadataSources, + mockProgress, + mockUser +} from "./mockData"; +import type { CbzPagesDto, ContinueItem, ReaderPreferencesDto, Session } from "./types"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; +const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:"; type RequestOptions = RequestInit & { fallback?: unknown; @@ -28,20 +44,54 @@ export class ApiFallbackError extends Error { } } +export class ApiHttpError extends Error { + constructor( + public readonly status: number, + message: string + ) { + super(message); + } +} + +export function getApiFallback(error: unknown): T | undefined { + return error instanceof ApiFallbackError ? (error.fallback as T) : undefined; +} + +function apiErrorMessage(detail: string, fallback: string): string { + if (!detail) return fallback; + try { + const parsed = JSON.parse(detail) as { message?: unknown; error?: unknown }; + if (typeof parsed.message === "string") return parsed.message; + if (Array.isArray(parsed.message)) return parsed.message.join(", "); + if (typeof parsed.error === "string") return parsed.error; + } catch { + return detail; + } + return fallback; +} + +function requestHeaders(options: RequestOptions): Headers { + const headers = new Headers(options.headers); + if (options.body !== undefined && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + return headers; +} + async function request(path: string, options: RequestOptions = {}): Promise { try { const response = await fetch(`${API_BASE}${path}`, { ...options, credentials: "include", - headers: { - "Content-Type": "application/json", - ...options.headers - } + headers: requestHeaders(options) }); if (!response.ok) { + if (response.status === 401 && typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("readabook:session-expired")); + } const detail = await response.text(); - throw new Error(detail || `${response.status} ${response.statusText}`); + throw new ApiHttpError(response.status, apiErrorMessage(detail, `${response.status} ${response.statusText}`)); } return (await response.json()) as T; @@ -62,6 +112,30 @@ function queryString(query: Partial): string { return value ? `?${value}` : ""; } +function readerPreferencesKey(bookId: number): string { + return `${READER_PREFERENCES_PREFIX}${bookId}`; +} + +function readLocalReaderPreferences(bookId: number): ReaderPreferencesDto { + if (typeof localStorage === "undefined") return { mode: "horizontal", fit: "page" }; + const raw = localStorage.getItem(readerPreferencesKey(bookId)); + if (!raw) return { mode: "horizontal", fit: "page" }; + try { + const parsed = JSON.parse(raw) as Partial; + return { + mode: parsed.mode === "vertical" ? "vertical" : "horizontal", + fit: parsed.fit === "width" ? "width" : "page" + }; + } catch { + return { mode: "horizontal", fit: "page" }; + } +} + +function writeLocalReaderPreferences(bookId: number, preferences: ReaderPreferencesDto): void { + if (typeof localStorage === "undefined") return; + localStorage.setItem(readerPreferencesKey(bookId), JSON.stringify(preferences)); +} + export const api = { async session(): Promise { try { @@ -71,6 +145,9 @@ export const api = { return { user: null, degraded: false }; } }, + async authStatus(): Promise { + return request("/auth/status"); + }, async bootstrap(input: BootstrapAdminDto): Promise { const result = await request("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) }); return result; @@ -82,11 +159,14 @@ export const api = { async logout(): Promise { await request<{ ok: true }>("/auth/logout", { method: "POST" }); }, + async updateMe(input: UpdateAccountDto): Promise { + return request("/auth/me", { method: "PATCH", body: JSON.stringify(input) }); + }, async books(query: Partial = {}): Promise { - return request(`/books${queryString({ limit: 50, offset: 0, ...query })}`, { fallback: mockBooks }); + return request(`/books${queryString(query)}`, { fallback: mockBooks }); }, async search(query: string): Promise { - return request(`/books/search${queryString({ q: query, limit: 50, offset: 0 })}`, { fallback: mockBooks }); + return request(`/books/search${queryString({ q: query })}`, { fallback: mockBooks }); }, async book(id: number): Promise { const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0]; @@ -98,6 +178,12 @@ export const api = { bookCoverUrl(id: number): string { return `${API_BASE}/books/${id}/cover`; }, + async cbzPages(id: number): Promise { + return request(`/books/${id}/pages`); + }, + cbzPageUrl(id: number, page: number): string { + return `${API_BASE}/books/${id}/pages/${page}`; + }, async progress(bookId: number): Promise { try { return await request(`/progress/${bookId}`, { @@ -115,6 +201,31 @@ export const api = { fallback: { bookId, ...input, updatedAt: new Date().toISOString() } }); }, + async readerPreferences(bookId: number): Promise { + try { + const preferences = await request(`/reader/preferences/${bookId}`, { + fallback: readLocalReaderPreferences(bookId) + }); + writeLocalReaderPreferences(bookId, preferences); + return preferences; + } catch (error) { + if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto; + return readLocalReaderPreferences(bookId); + } + }, + async saveReaderPreferences(bookId: number, input: ReaderPreferencesDto): Promise { + writeLocalReaderPreferences(bookId, input); + try { + return await request(`/reader/preferences/${bookId}`, { + method: "PUT", + body: JSON.stringify(input), + fallback: input + }); + } catch (error) { + if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto; + return input; + } + }, async continueReading(): Promise { return request("/progress/continue", { fallback: mockContinue }); }, @@ -124,17 +235,45 @@ export const api = { async createLibrary(input: CreateLibraryDto): Promise { return request("/admin/libraries", { method: "POST", - body: JSON.stringify(input), - fallback: { id: Date.now(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...input } + body: JSON.stringify(input) }); }, + async deleteLibrary(id: number): Promise { + await request<{ ok: true }>(`/admin/libraries/${id}`, { method: "DELETE" }); + }, async scanLibrary(id: number): Promise { - return request(`/admin/libraries/${id}/scan`, { method: "POST", fallback: mockJobs[0] }); + return request(`/admin/libraries/${id}/scan`, { method: "POST" }); }, async jobs(): Promise { return request("/admin/jobs", { fallback: mockJobs }); }, async users(): Promise { return request("/admin/users", { fallback: [mockUser] }); + }, + async metadataSources(): Promise { + return request("/admin/metadata-sources", { fallback: mockMetadataSources }); + }, + async updateMetadataSources(input: UpdateMetadataSourcesConfigDto): Promise { + return request("/admin/metadata-sources", { + method: "PUT", + body: JSON.stringify(input), + fallback: mockMetadataSources + }); + }, + async automationSettings(): Promise { + return request("/admin/automation", { fallback: mockAutomationSettings }); + }, + async updateAutomationSettings(input: UpdateAutomationSettingsDto): Promise { + return request("/admin/automation", { + method: "PUT", + body: JSON.stringify(input), + fallback: mockAutomationSettings + }); + }, + async runAutomationScan(): Promise { + return request("/admin/automation/run-scan", { method: "POST", fallback: mockJobs[0] }); + }, + async runAutomationEnrich(): Promise { + return request("/admin/automation/run-enrich", { method: "POST", fallback: mockJobs[0] }); } }; diff --git a/apps/web/src/api/mockData.ts b/apps/web/src/api/mockData.ts index 0f70fc7..569c6f6 100644 --- a/apps/web/src/api/mockData.ts +++ b/apps/web/src/api/mockData.ts @@ -1,8 +1,27 @@ -import type { BookDto, JobDto, LibraryDto, ProgressDto, UserDto } from "@readabook/shared"; +import type { AutomationSettingsDto, BookDto, JobDto, LibraryDto, MetadataSourcesConfigDto, ProgressDto, UserDto } from "@readabook/shared"; 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,13 +36,14 @@ export const mockLibraries: LibraryDto[] = [ ]; export const mockBooks: BookDto[] = [ - { + mockBook({ id: 1, libraryId: 1, title: "L'Herbier des machines", author: "M. Valrose", description: "Fragments, croquis et notes rassemblees autour d'automates introuvables.", isbn: null, + isbn13: null, language: "fr", publisher: "Cabinet ReadaBook", publishedDate: "1908", @@ -34,14 +54,15 @@ export const mockBooks: BookDto[] = [ fileMtime: now, createdAt: now, updatedAt: now - }, - { + }), + mockBook({ id: 2, libraryId: 2, title: "Cartographie des songes", author: "I. Nadir", description: "Un atlas annote ou chaque page devient une vitrine de lecture.", isbn: null, + isbn13: null, language: "fr", publisher: "ReadaBook", publishedDate: "1921", @@ -52,12 +73,52 @@ export const mockBooks: BookDto[] = [ fileMtime: now, createdAt: now, updatedAt: now - } + }), + mockBook({ + id: 3, + libraryId: 2, + title: "Les vitrines de verre", + author: "A. Muze", + description: "Un recit graphique indexe comme archive CBZ.", + isbn: null, + isbn13: null, + language: "fr", + publisher: "ReadaBook", + publishedDate: "1934", + format: "cbz", + filePath: "/library/cbz/vitrines.cbz", + coverPath: null, + fileSize: 12600000, + fileMtime: now, + createdAt: now, + updatedAt: now + }), + mockBook({ + id: 4, + libraryId: 2, + title: "Cabinet noir", + author: "L. Rar", + description: "Archive CBR lue avec le même parcours paginé que les comics CBZ.", + isbn: null, + isbn13: null, + language: "fr", + publisher: "ReadaBook", + publishedDate: "1937", + format: "cbr", + filePath: "/library/cbr/cabinet-noir.cbr", + coverPath: null, + fileSize: 14800000, + fileMtime: now, + createdAt: now, + updatedAt: now + }) ]; export const mockProgress: ProgressDto[] = [ { bookId: 1, locator: "mock:chapter-3", percent: 42, updatedAt: now }, - { bookId: 2, locator: "mock:page-12", percent: 18, updatedAt: now } + { bookId: 2, locator: "pdf:page:12", percent: 18, updatedAt: now }, + { bookId: 3, locator: "cbz:page:4", percent: 40, updatedAt: now }, + { bookId: 4, locator: "cbr:page:6", percent: 60, updatedAt: now } ]; export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({ @@ -68,3 +129,20 @@ export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({ export const mockJobs: JobDto[] = [ { id: 1, type: "scan-library", status: "succeeded", detail: "2 ouvrages indexes", error: null, createdAt: now, updatedAt: now } ]; + +export const mockMetadataSources: MetadataSourcesConfigDto = { + isbnPriorityEnabled: true, + sources: [ + { provider: "local", enabled: true, priority: 0, hasApiKey: false }, + { provider: "openlibrary", enabled: true, priority: 1, hasApiKey: false }, + { provider: "googlebooks", enabled: false, priority: 2, hasApiKey: false }, + { provider: "bnf", enabled: false, priority: 3, hasApiKey: false } + ] +}; + +export const mockAutomationSettings: AutomationSettingsDto = { + watchLibraries: false, + autoEnrichNewBooks: true, + scanSchedule: { frequency: "disabled", time: "03:00", dayOfWeek: 1 }, + enrichSchedule: { frequency: "weekly", time: "04:00", dayOfWeek: 1 } +}; diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index cf08628..af8a676 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -21,3 +21,32 @@ export type DashboardData = { libraries: LibraryDto[]; jobs: JobDto[]; }; + +export type CbzPagesDto = { + bookId: number; + pageCount: number; + pages: Array<{ page: number; name: string }>; +}; + +export type ReaderMode = "horizontal" | "vertical"; + +export type ReaderFit = "page" | "width"; + +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/auth/errors.test.ts b/apps/web/src/auth/errors.test.ts new file mode 100644 index 0000000..35f5594 --- /dev/null +++ b/apps/web/src/auth/errors.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { ApiHttpError } from "../api/client"; +import { loginErrorMessage } from "./errors"; + +describe("login error messages", () => { + it("maps invalid credentials", () => { + expect(loginErrorMessage(new ApiHttpError(401, "Invalid credentials"))).toBe("Identifiants invalides."); + expect(loginErrorMessage(new ApiHttpError(403, "Forbidden"))).toBe("Identifiants invalides."); + }); + + it("maps server and network errors", () => { + expect(loginErrorMessage(new ApiHttpError(500, "Internal error"))).toBe("Serveur d'authentification indisponible."); + expect(loginErrorMessage(new TypeError("fetch failed"))).toBe("Connexion au serveur impossible."); + }); +}); diff --git a/apps/web/src/auth/errors.ts b/apps/web/src/auth/errors.ts new file mode 100644 index 0000000..22089d7 --- /dev/null +++ b/apps/web/src/auth/errors.ts @@ -0,0 +1,11 @@ +import { ApiHttpError } from "../api/client"; + +export function loginErrorMessage(error: unknown): string { + if (error instanceof ApiHttpError) { + if (error.status === 401 || error.status === 403) return "Identifiants invalides."; + if (error.status >= 500) return "Serveur d'authentification indisponible."; + return "Connexion impossible."; + } + if (error instanceof TypeError) return "Connexion au serveur impossible."; + return "Connexion impossible."; +} diff --git a/apps/web/src/auth/routing.test.ts b/apps/web/src/auth/routing.test.ts new file mode 100644 index 0000000..0e53960 --- /dev/null +++ b/apps/web/src/auth/routing.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { isPrivateRoute, isPublicRoute } from "./routing"; + +describe("auth route guards", () => { + it("keeps login and setup public", () => { + expect(isPublicRoute({ name: "login" })).toBe(true); + expect(isPublicRoute({ name: "setup", step: "admin" })).toBe(true); + }); + + it("marks catalogue routes private", () => { + expect(isPrivateRoute({ name: "home" })).toBe(true); + expect(isPrivateRoute({ name: "search" })).toBe(true); + expect(isPrivateRoute({ name: "book", bookId: 1 })).toBe(true); + }); +}); diff --git a/apps/web/src/auth/routing.ts b/apps/web/src/auth/routing.ts new file mode 100644 index 0000000..8c051a4 --- /dev/null +++ b/apps/web/src/auth/routing.ts @@ -0,0 +1,9 @@ +import type { Route } from "../router"; + +export function isPublicRoute(route: Route): boolean { + return route.name === "login" || route.name === "setup"; +} + +export function isPrivateRoute(route: Route): boolean { + return !isPublicRoute(route); +} diff --git a/apps/web/src/book/description.test.ts b/apps/web/src/book/description.test.ts new file mode 100644 index 0000000..39f93f7 --- /dev/null +++ b/apps/web/src/book/description.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { cleanBookDescription } from "./description"; + +describe("cleanBookDescription", () => { + it("renders catalog HTML as readable plain text", () => { + expect(cleanBookDescription("

Premier & second.

Suite du texte.

")).toBe( + "Premier & second.\nSuite du texte." + ); + }); + + it("falls back when the description is empty after cleanup", () => { + expect(cleanBookDescription("

")).toBe("Notice absente du catalogue."); + }); +}); diff --git a/apps/web/src/book/description.ts b/apps/web/src/book/description.ts new file mode 100644 index 0000000..83851b3 --- /dev/null +++ b/apps/web/src/book/description.ts @@ -0,0 +1,28 @@ +const blockBreakPattern = /<\/(p|div|section|article|header|footer|blockquote|li|ul|ol|br|h[1-6])>/gi; +const tagPattern = /<[^>]*>/g; + +function decodeEntities(value: string): string { + if (typeof document === "undefined") { + return value + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/gi, "'"); + } + const textarea = document.createElement("textarea"); + textarea.innerHTML = value; + return textarea.value; +} + +export function cleanBookDescription(description?: string | null): string { + if (!description) return "Notice absente du catalogue."; + return decodeEntities(description.replace(blockBreakPattern, "\n").replace(tagPattern, " ")) + .replace(/\r/g, "") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n[ \t]+/g, "\n") + .replace(/[ \t]{2,}/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim() || "Notice absente du catalogue."; +} 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."}

}
+ +
+ + + {tab === "sources" ? ( + setMetadataState((current) => ({ ...current, draft, success: undefined }))} + onSubmit={saveMetadata} + onRefresh={refreshMetadata} + /> + ) : ( + setAutomationState((current) => ({ ...current, draft, success: undefined }))} + onSubmit={saveAutomation} + onRefresh={refreshAutomation} + onRunNow={runNow} + /> + )} +
+ ); +} + +function MetadataSourcesPanel({ + state, + dirty, + apiKeys, + setApiKeys, + onChange, + onSubmit, + onRefresh +}: { + state: ApiState; + dirty: boolean; + apiKeys: Partial>; + setApiKeys: (next: Partial>) => void; + onChange: (draft: AdminMetadataSourcesConfig) => void; + onSubmit: (event: FormEvent) => void; + onRefresh: () => Promise; +}) { + const draft = state.draft; + if (state.loading && !draft) return ; + if (!draft) return null; + + const local = draft.sources.find((source) => source.provider === "local"); + const external = draft.sources.filter((source) => source.provider !== "local"); + + return ( +
+ +
+
+

Sources de métadonnées

+

La source locale reste active en premier passage.

+
+ +
+ + {state.success &&
{state.success}
} + +
+ + +
+ {local && ( +
+
+ {providerLabels.local} + Source locale +
+ toujours active +
+ )} + {external.map((source, index) => ( +
+ +
+
+ {providerUiStateLabel(source)} + {providerUiMessage(source)} +
+ {source.provider === "comicvine" && ( +

+ Usage non commercial uniquement. Verifier la compatibilite avec l'usage de ReadaBook. +

+ )} + +
+
+ + +
+
+ ))} +
+
+ + + + ); +} + +function AutomationPanel({ + state, + dirty, + onChange, + onSubmit, + onRefresh, + onRunNow +}: { + state: ApiState; + dirty: boolean; + onChange: (draft: AutomationSettingsDto) => void; + onSubmit: (event: FormEvent) => void; + onRefresh: () => Promise; + onRunNow: (kind: "scan" | "enrich") => Promise; +}) { + const draft = state.draft; + if (state.loading && !draft) return ; + if (!draft) return null; + + return ( +
+ +
+
+

Automatisation

+

Surveillance des dossiers et traitements planifies.

+
+ +
+ + {state.success &&
{state.success}
} +
+ + +
+
+ + onRunNow("scan")} + onChange={(scanSchedule) => onChange({ ...draft, scanSchedule })} + /> + onRunNow("enrich")} + onChange={(enrichSchedule) => onChange({ ...draft, enrichSchedule })} + /> + + + + ); +} + +function SchedulePanel({ + title, + schedule, + summary, + runLabel, + onRun, + onChange +}: { + title: string; + schedule: AutomationScheduleDto; + summary: string; + runLabel: string; + onRun: () => Promise; + onChange: (schedule: AutomationScheduleDto) => void; +}) { + function patch(next: Partial) { + onChange({ ...schedule, ...next }); + } + + return ( + +
+
+

{title}

+

{summary}

+
+ +
+
+ + + {schedule.frequency === "weekly" && ( + + )} +
+
+ ); +} + +function SaveBar({ dirty, saving, onRefresh }: { dirty: boolean; saving: boolean; onRefresh: () => Promise }) { + return ( + + {dirty ? "Modifications en attente." : "Aucune modification en attente."} +
+ + +
+
+ ); +} + +function StatusText({ dirty, loading }: { dirty: boolean; loading: boolean }) { + if (loading) return chargement; + return {dirty ? "non enregistre" : "synchronise"}; +} + +function LoadingPanel({ label }: { label: string }) { + return ( + + + + ); +} diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index bfc3106..6f72640 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -1,67 +1,152 @@ import { FormEvent, useEffect, useState } from "react"; -import { Play, Plus } from "lucide-react"; +import { Play, Plus, Trash2 } from "lucide-react"; import type { JobDto, LibraryDto, UserDto } from "@readabook/shared"; -import { api } from "../api/client"; -import { ErrorRibbon, LoadingState, Panel } from "../components/ui"; +import { api, getApiFallback } from "../api/client"; +import { jobDigestSummary } from "../book/metadata"; +import { EmptyState, ErrorRibbon, LoadingState, Panel } from "../components/ui"; + +function formatJobTime(value: string) { + return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(value)); +} export function AdminPage() { - const [libraries, setLibraries] = useState(null); + const [libraries, setLibraries] = useState([]); const [jobs, setJobs] = useState([]); const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); const [name, setName] = useState("Bibliotheque locale"); const [path, setPath] = useState("/library"); const [error, setError] = useState(); + const [success, setSuccess] = useState(); + const [scanRetryLibrary, setScanRetryLibrary] = useState(); async function refresh() { - const [nextLibraries, nextJobs, nextUsers] = await Promise.all([api.libraries(), api.jobs(), api.users()]); - setLibraries(nextLibraries); - setJobs(nextJobs); - setUsers(nextUsers); + setLoading(true); + setError(undefined); + const [libraryResult, jobResult, userResult] = await Promise.allSettled([api.libraries(), api.jobs(), api.users()]); + const errors: string[] = []; + + if (libraryResult.status === "fulfilled") { + setLibraries(libraryResult.value); + } else { + setLibraries(getApiFallback(libraryResult.reason) ?? []); + errors.push("bibliotheques"); + } + + if (jobResult.status === "fulfilled") { + setJobs(jobResult.value); + } else { + setJobs(getApiFallback(jobResult.reason) ?? []); + errors.push("travaux"); + } + + if (userResult.status === "fulfilled") { + setUsers(userResult.value); + } else { + setUsers(getApiFallback(userResult.reason) ?? []); + errors.push("comptes"); + } + + setError(errors.length ? `Donnees admin degradees : ${errors.join(", ")}.` : undefined); + setLoading(false); } useEffect(() => { - refresh().catch((refreshError) => setError(refreshError instanceof Error ? refreshError.message : "Administration indisponible")); + void refresh(); }, []); async function createLibrary(event: FormEvent) { event.preventDefault(); setError(undefined); + setSuccess(undefined); + setScanRetryLibrary(undefined); try { - await api.createLibrary({ name, path, enabled: true }); + const created = await api.createLibrary({ name, path, enabled: true }); await refresh(); + setName("Bibliotheque locale"); + setPath("/library"); + try { + await api.scanLibrary(created.id); + await refresh(); + setSuccess(`Bibliothèque "${created.name}" ajoutée. Scan initial demandé.`); + } catch (scanError) { + setScanRetryLibrary(created); + setSuccess( + `Bibliothèque "${created.name}" ajoutée, mais le scan initial n'a pas pu être demandé. Tu peux réessayer le scan.` + ); + setError(scanError instanceof Error ? `Scan initial impossible : ${scanError.message}` : "Scan initial impossible."); + } } catch (createError) { - setError(createError instanceof Error ? createError.message : "Creation impossible"); + setError(createError instanceof Error ? `Création impossible : ${createError.message}` : "Création impossible."); } } async function scan(id: number) { setError(undefined); + setSuccess(undefined); + setScanRetryLibrary(undefined); try { await api.scanLibrary(id); await refresh(); + setSuccess("Scan demandé."); } catch (scanError) { - setError(scanError instanceof Error ? scanError.message : "Scan impossible"); + setError(scanError instanceof Error ? `Scan impossible : ${scanError.message}` : "Scan impossible."); } } - if (!libraries) return ; + async function deleteLibrary(library: LibraryDto) { + const confirmed = window.confirm( + `Supprimer la bibliothèque "${library.name}" ?\n\nLes livres restent sur le disque. ReadaBook supprimera seulement cette bibliothèque du catalogue.` + ); + if (!confirmed) return; + + setError(undefined); + setSuccess(undefined); + setScanRetryLibrary(undefined); + try { + await api.deleteLibrary(library.id); + setLibraries((current) => current.filter((item) => item.id !== library.id)); + setSuccess(`Bibliothèque "${library.name}" supprimée. Les fichiers disque n'ont pas été supprimés.`); + } catch (deleteError) { + setError(deleteError instanceof Error ? deleteError.message : "Suppression impossible"); + } + } return (

Administration

- {users.length} comptes + {loading ? "chargement" : `${users.length} comptes`}
+ {success &&
{success}
} + {scanRetryLibrary ? ( +
+ La bibliothèque est conservée dans la liste. + +
+ ) : error ? ( +
+ Les formulaires restent disponibles. + +
+ ) : null}
-
- {jobs.map((job) => ( -
- {job.type} - {job.status} -
- ))} -
+ {loading && !jobs.length ? ( + + ) : jobs.length ? ( +
+ {jobs.map((job) => ( +
+
+
+ {job.type} + {jobDigestSummary(job)} +
+ +
+ {job.status} +
+ ))} +
+ ) : ( + + )} -
- {libraries.map((library) => ( -
-
- {library.name} - {library.path} + {loading && !libraries.length ? ( + + ) : libraries.length ? ( +
+ {libraries.map((library) => ( +
+
+ {library.name} + Chemin : {library.path} +
+ {library.enabled ? "actif" : "pause"} + +
- {library.enabled ? "actif" : "pause"} - -
- ))} -
+ ))} +
+ ) : ( + + )}
); diff --git a/apps/web/src/pages/BookErrorPages.test.ts b/apps/web/src/pages/BookErrorPages.test.ts new file mode 100644 index 0000000..5e0e65a --- /dev/null +++ b/apps/web/src/pages/BookErrorPages.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("book missing error pages", () => { + it("does not render fallback books on the book detail page", () => { + const source = readFileSync(new URL("./BookPage.tsx", import.meta.url), "utf8"); + + expect(source).not.toContain("getApiFallback"); + expect(source).toContain("setBook(null)"); + expect(source).toContain("Livre introuvable"); + expect(source).toContain("Ce livre n'existe pas dans le catalogue."); + }); + + it("does not open the reader with a fallback book", () => { + const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8"); + + expect(source).not.toContain("getApiFallback"); + expect(source).toContain("setBook(null)"); + expect(source).toContain("reader-missing-page"); + expect(source).toContain("Livre introuvable"); + expect(source).toContain("Ce livre n'existe pas dans le catalogue."); + }); +}); diff --git a/apps/web/src/pages/BookPage.tsx b/apps/web/src/pages/BookPage.tsx index 440adb1..5b41915 100644 --- a/apps/web/src/pages/BookPage.tsx +++ b/apps/web/src/pages/BookPage.tsx @@ -1,27 +1,79 @@ import { useEffect, useState } from "react"; -import { BookOpen, LibraryBig } from "lucide-react"; +import { BookOpen, LibraryBig, RotateCcw } from "lucide-react"; import type { BookDto, ProgressDto } from "@readabook/shared"; import { api } from "../api/client"; -import { FormatPill, LoadingState, Meter, Panel } from "../components/ui"; +import { cleanBookDescription } from "../book/description"; +import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata"; +import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui"; import { navigate } from "../router"; export function BookPage({ bookId }: { bookId: number }) { const [book, setBook] = useState(null); const [progress, setProgress] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + + async function loadBook() { + setLoading(true); + setError(undefined); + try { + const nextBook = await api.book(bookId); + setBook(nextBook); + try { + setProgress(await api.progress(bookId)); + } catch { + setProgress(null); + } + } catch { + setBook(null); + setProgress(null); + setError("Ce livre n'existe pas dans le catalogue."); + } finally { + setLoading(false); + } + } useEffect(() => { let alive = true; - Promise.all([api.book(bookId), api.progress(bookId)]).then(([nextBook, nextProgress]) => { + loadBook().finally(() => { if (!alive) return; - setBook(nextBook); - setProgress(nextProgress); }); return () => { alive = false; }; }, [bookId]); - if (!book) return ; + if (loading && !book) return ; + if (!book) { + return ( +
+ + +
+ + +
+
+
+ ); + } + + const series = bookSeriesInfo(book); + const metadataState = bookMetadataState(book); + const publishedDate = displayPublishedDate(book.publishedDate); + const detailFacts = [ + { label: "Auteur", value: book.author }, + { label: "Date", value: publishedDate }, + { label: "Serie", value: series?.title }, + { label: "Position", value: series?.volumeLabel }, + { label: "Editeur", value: book.publisher }, + { label: "ISBN", value: book.isbn13 ?? book.isbn } + ].filter((fact) => fact.value); return (
@@ -29,13 +81,23 @@ export function BookPage({ bookId }: { bookId: number }) { {book.coverPath ? : } +
{book.language ?? "langue inconnue"} + {bookMetadataStateLabel(book)}
-

{book.title}

+

{bookDisplayTitle(book)}

{book.author ?? "Auteur inconnu"}

-

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

+
+ {detailFacts.map((fact) => ( +
+
{fact.label}
+
{fact.value}
+
+ ))} +
+

{cleanBookDescription(book.description)}

{progress && }
+ {series && ( + + )} + {error && ( + + )}
diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx index f0daec1..19ee986 100644 --- a/apps/web/src/pages/HomePage.tsx +++ b/apps/web/src/pages/HomePage.tsx @@ -1,21 +1,19 @@ import { useEffect, useState } from "react"; -import { LibraryBig, ScanLine } from "lucide-react"; +import { BookOpen, LibraryBig, ScanLine } from "lucide-react"; import { api } from "../api/client"; import type { DashboardData } from "../api/types"; -import { BookCard } from "../components/BookCard"; +import { bookDisplayTitle, bookVolumeLabel } from "../book/metadata"; import { EmptyState, LoadingState, Meter, Panel } from "../components/ui"; import { navigate } from "../router"; export function HomePage() { const [state, setState] = useState(null); - const [fallback, setFallback] = useState(false); useEffect(() => { let alive = true; Promise.all([api.books(), api.continueReading(), api.libraries(), api.jobs()]) .then(([books, continueReading, libraries, jobs]) => { if (!alive) return; - setFallback(books.some((book) => book.filePath.startsWith("/library/")) && jobs.length === 1); setState({ books, continueReading, libraries, jobs }); }) .catch(() => { @@ -28,13 +26,32 @@ export function HomePage() { if (!state) return ; + const renderHomeBook = ( + item: DashboardData["books"][number], + className = "home-book-card", + options: { href?: string; progressPercent?: number } = {} + ) => { + const volumeLabel = bookVolumeLabel(item); + return ( + + ); + }; + return ( -
-
+
+
-

Cabinet de curiosites numerique

-

Ouvrir, classer, reprendre.

- {fallback ? "API absente ou incomplete : specimens de demonstration actifs." : "Catalogue branche sur le serveur local."} +

Reprendre la lecture.

- ))} + {state.continueReading.map((item) => + renderHomeBook(item.book, "home-book-card continue-tile", { + href: `/reader/${item.book.id}`, + progressPercent: item.progress.percent + }) + )}
) : ( @@ -77,10 +93,8 @@ export function HomePage() {
-
- {state.books.map((book) => ( - - ))} +
+ {state.books.map((book) => renderHomeBook(book))}
); diff --git a/apps/web/src/pages/HomePageProgressScroll.test.ts b/apps/web/src/pages/HomePageProgressScroll.test.ts new file mode 100644 index 0000000..ffd0dd7 --- /dev/null +++ b/apps/web/src/pages/HomePageProgressScroll.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("home progress list layout", () => { + it("keeps the continue reading list scrollable after three visible books", () => { + const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8"); + const continueTile = styles.match(/\.continue-tile\s*\{[^}]+\}/)?.[0] ?? ""; + + expect(styles).toContain(".continue-grid,\n.library-list {\n --continue-visible-rows: 3;"); + expect(styles).toContain("--continue-tile-block-size: 112px"); + expect(styles).toContain("max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))"); + expect(styles).toContain("overflow-y: auto"); + expect(styles).toContain("scrollbar-gutter: stable"); + expect(continueTile).toContain("block-size: var(--continue-tile-block-size)"); + expect(continueTile).toContain("overflow: hidden"); + }); + + it("keeps home book cards scoped and simplified", () => { + const source = readFileSync(new URL("./HomePage.tsx", import.meta.url), "utf8"); + + expect(source).toContain("renderHomeBook"); + expect(source).toContain("home-book-card"); + expect(source).toContain("progressPercent"); + expect(source).toContain(""); + expect(source).toContain("progressPercent: item.progress.percent"); + expect(source).not.toContain(" { + const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8"); + const bookGrid = styles.match(/\.book-grid\s*\{[^}]+\}/)?.[0] ?? ""; + const homeBookGrid = styles.match(/\.home-book-grid\s*\{[^}]+\}/)?.[0] ?? ""; + + expect(bookGrid).toContain("grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr))"); + expect(homeBookGrid).toContain("grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr))"); + expect(bookGrid).toContain("width: 100%"); + expect(homeBookGrid).toContain("width: 100%"); + expect(bookGrid).not.toContain("max-width"); + expect(homeBookGrid).not.toContain("max-width"); + }); + + it("keeps the library list capped like continue reading", () => { + const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8"); + const libraryButton = styles.match(/\.library-list button\s*\{[^}]+\}/)?.[0] ?? ""; + + expect(styles).toContain(".continue-grid,\n.library-list {\n --continue-visible-rows: 3;"); + expect(styles).toContain("height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))"); + expect(styles).toContain("max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))"); + expect(libraryButton).toContain("block-size: var(--continue-tile-block-size)"); + expect(libraryButton).toContain("overflow: hidden"); + }); +}); 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/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 9195481..98de627 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -1,14 +1,36 @@ -import { FormEvent, useState } from "react"; -import { KeyRound, LogIn } from "lucide-react"; +import { FormEvent, useEffect, useState } from "react"; +import { KeyRound, LogIn, ShieldAlert } from "lucide-react"; +import type { AuthStatusDto } from "@readabook/shared"; import { api } from "../api/client"; +import { loginErrorMessage } from "../auth/errors"; import { navigate } from "../router"; import { ErrorRibbon, Panel } from "../components/ui"; +const DEFAULT_INITIAL_PASSWORD = "readabook-admin-change-me"; + export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise }) { - const [email, setEmail] = useState("admin@readabook.local"); + const [status, setStatus] = useState(null); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(); + useEffect(() => { + let alive = true; + api + .authStatus() + .then((nextStatus) => { + if (!alive) return; + setStatus(nextStatus); + setEmail((current) => current || nextStatus.initialAdminEmail); + }) + .catch(() => { + if (alive) setError("Statut d'authentification indisponible."); + }); + return () => { + alive = false; + }; + }, []); + async function submit(event: FormEvent) { event.preventDefault(); setError(undefined); @@ -17,7 +39,7 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise< await onSessionChange(); navigate("/home"); } catch (loginError) { - setError(loginError instanceof Error ? loginError.message : "Connexion impossible"); + setError(loginErrorMessage(loginError)); } } @@ -32,6 +54,23 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<

Entrer dans le cabinet

+ {status?.hasUsers && ( +
+ +
+ Acces admin initial + {status.initialAdminEmail} + {status.initialAdminPasswordIsDefault ? ( + <> + {DEFAULT_INITIAL_PASSWORD} + Mot de passe par defaut atteste par le serveur. Change-le dans Mon compte > Securite. + + ) : ( + Utilise le mot de passe configure au demarrage ou deja modifie dans le compte. + )} +
+
+ )}
); diff --git a/apps/web/src/pages/ProfilePage.tsx b/apps/web/src/pages/ProfilePage.tsx index e45f2a1..5a3f43f 100644 --- a/apps/web/src/pages/ProfilePage.tsx +++ b/apps/web/src/pages/ProfilePage.tsx @@ -1,27 +1,95 @@ -import { LogOut, UserRound } from "lucide-react"; +import { FormEvent, useState } from "react"; +import { KeyRound, LogOut, UserRound } from "lucide-react"; import type { Session } from "../api/types"; import { api } from "../api/client"; -import { Panel } from "../components/ui"; +import { ErrorRibbon, Panel } from "../components/ui"; import { navigate } from "../router"; export function ProfilePage({ session, onSessionChange }: { session: Session; onSessionChange: () => Promise }) { + const [email, setEmail] = useState(session.user?.email ?? ""); + const [name, setName] = useState(session.user?.name ?? ""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [error, setError] = useState(); + const [success, setSuccess] = useState(); + async function logout() { await api.logout(); await onSessionChange(); navigate("/login"); } + async function updateSecurity(event: FormEvent) { + event.preventDefault(); + setError(undefined); + setSuccess(undefined); + try { + await api.updateMe({ + email: email === session.user?.email ? undefined : email, + name: name || null, + currentPassword, + newPassword: newPassword || undefined + }); + setCurrentPassword(""); + setNewPassword(""); + setSuccess("Identifiants mis a jour."); + await onSessionChange(); + } catch (updateError) { + setError(updateError instanceof Error ? updateError.message : "Mise a jour impossible"); + } + } + return (
- - -

{session.user?.name ?? "Lecteur invite"}

-

{session.user?.email ?? "Session non connectee"}

- {session.user?.role ?? "vitrine"} - + +
+ +

{session.user?.name ?? "Lecteur invite"}

+

{session.user?.email ?? "Session non connectee"}

+ {session.user?.role ?? "vitrine"} +
+
+ + +
+
+ +
+

Securite

+ +
+

Change l'email et le mot de passe admin initial des que le cabinet est installe.

+ + {success &&
{success}
} +
+ + + + + +
); diff --git a/apps/web/src/pages/ProfilePagePolish.test.ts b/apps/web/src/pages/ProfilePagePolish.test.ts new file mode 100644 index 0000000..07dc523 --- /dev/null +++ b/apps/web/src/pages/ProfilePagePolish.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("profile page polish", () => { + it("focuses the left column on identity and immediate actions", () => { + const source = readFileSync(new URL("./ProfilePage.tsx", import.meta.url), "utf8"); + const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8"); + + expect(source).toContain("profile-identity"); + expect(source).toContain("profile-avatar"); + expect(source).toContain("profile-actions"); + expect(source).toContain('id="profile-security-form"'); + expect(source).toContain("scrollIntoView"); + expect(styles).toContain(".profile-identity"); + expect(styles).toContain(".profile-actions"); + }); +}); diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx index 21763fc..3b32ebb 100644 --- a/apps/web/src/pages/ReaderPage.tsx +++ b/apps/web/src/pages/ReaderPage.tsx @@ -1,57 +1,306 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { ArrowLeft, Save } from "lucide-react"; +import { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react"; import type { BookDto } from "@readabook/shared"; import { api } from "../api/client"; -import { LoadingState, Meter } from "../components/ui"; -import { navigate } from "../router"; +import { CbzReader } from "../reader/CbzReader"; import { EpubReader } from "../reader/EpubReader"; +import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators"; import { PdfReader } from "../reader/PdfReader"; +import { ReaderShell, type ReaderControls, type ReaderModeControls, type ReaderZoomAnchor, type ReaderZoomControls } from "../reader/ReaderShell"; +import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reader/readerLayout"; +import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll"; +import { useReaderPreferences } from "../reader/useReaderPreferences"; import { useReaderProgress } from "../reader/useReaderProgress"; +import { navigate } from "../router"; +import { EmptyState, LoadingState, Panel } from "../components/ui"; + +const idleControls: ReaderControls = { + canPrevious: false, + canNext: false, + positionLabel: "Chargement", + onPrevious: () => undefined, + onNext: () => undefined +}; + +type ReaderCrashBoundaryProps = { + resetKey: string; + onError: (error: Error) => void; + fallbackRender: (error: Error, retry: () => void) => ReactNode; + children: ReactNode; +}; + +type ReaderCrashBoundaryState = { + error: Error | null; +}; + +class ReaderCrashBoundary extends Component { + state: ReaderCrashBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidCatch(error: Error, _errorInfo: ErrorInfo) { + this.props.onError(error); + } + + componentDidUpdate(previousProps: ReaderCrashBoundaryProps) { + if (previousProps.resetKey !== this.props.resetKey && this.state.error) { + this.setState({ error: null }); + } + } + + retry = () => { + this.setState({ error: null }); + }; + + render() { + if (this.state.error) return this.props.fallbackRender(this.state.error, this.retry); + return this.props.children; + } +} export function ReaderPage({ bookId }: { bookId: number }) { const [book, setBook] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); const [page, setPage] = useState(1); - const { progress, saving, save } = useReaderProgress(bookId); + const [zoom, setZoom] = useState(READER_ZOOM_DEFAULT); + const [mode, setMode] = useState("horizontal"); + const [readerControls, setReaderControls] = useState(idleControls); + const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId); + const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId); + + async function loadBook() { + setLoading(true); + setError(undefined); + try { + setBook(await api.book(bookId)); + } catch { + setBook(null); + setError("Ce livre n'existe pas dans le catalogue."); + } finally { + setLoading(false); + } + } useEffect(() => { - api.book(bookId).then(setBook); + void loadBook(); }, [bookId]); useEffect(() => { - if (progress?.locator.startsWith("pdf:page:")) setPage(Number(progress.locator.split(":").at(-1)) || 1); + setReaderControls(idleControls); + setPage(1); + setZoom(READER_ZOOM_DEFAULT); + setMode("horizontal"); + }, [bookId]); + + useEffect(() => { + if (book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr") setMode(preferences.mode); + else setMode("horizontal"); + }, [book?.format, preferences.mode]); + + useEffect(() => { + const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator); + if (nextPage) setPage((current) => (current === nextPage ? current : nextPage)); }, [progress]); 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}`, Math.round((nextPage / pages) * 100)); + 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, anchor = 1, strategy: "immediate" | "queued" = "immediate") => { + setPage(nextPage); + const prefix = book?.format === "cbr" ? "cbr" : "cbz"; + 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, queueSave, save] + ); - if (!book) return ; + const readerError = error ?? progressError ?? preferencesError; + const supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr"; + const supportsMode = supportsZoom; + const currentVisiblePage = useCallback(() => { + const stage = document.querySelector(".reader-stage") as HTMLElement | null; + if (!stage) return null; + const stageRect = stage.getBoundingClientRect(); + const pages = Array.from(stage.querySelectorAll("[data-reader-page]")) + .map((element) => { + const rect = element.getBoundingClientRect(); + const pageNumber = Number(element.dataset.readerPage); + return Number.isFinite(pageNumber) ? { page: pageNumber, top: rect.top, bottom: rect.bottom } : null; + }) + .filter((item): item is { page: number; top: number; bottom: number } => Boolean(item)); + return majorityVisiblePage(pages, stageRect.top, stageRect.bottom); + }, []); + const changeMode = useCallback( + (nextMode: ReaderMode) => { + const anchorPage = currentVisiblePage() ?? page; + setPage(anchorPage); + setMode(nextMode); + saveReaderMode(nextMode); + }, + [currentVisiblePage, page, saveReaderMode] + ); + const returnToPagedMode = useCallback(() => { + setPage(currentVisiblePage() ?? page); + setMode("horizontal"); + saveReaderMode("horizontal"); + }, [currentVisiblePage, page, saveReaderMode]); + const changeZoom = useCallback((nextZoom: number | ((currentZoom: number) => number), anchor?: ReaderZoomAnchor) => { + const stage = document.querySelector(".reader-stage") as HTMLElement | null; + const scrollRatioX = stage && stage.scrollWidth > stage.clientWidth ? (stage.scrollLeft + stage.clientWidth / 2) / stage.scrollWidth : 0.5; + const scrollRatioY = stage && stage.scrollHeight > stage.clientHeight ? stage.scrollTop / (stage.scrollHeight - stage.clientHeight) : 0; + + setZoom((currentZoom) => clampReaderZoom(typeof nextZoom === "function" ? nextZoom(currentZoom) : nextZoom)); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!stage) return; + if (anchor) { + const target = stage.querySelector(`[data-reader-page="${anchor.page}"]`); + if (!target) return; + const rect = target.getBoundingClientRect(); + stage.scrollLeft += rect.left + anchor.offsetX - anchor.clientX; + stage.scrollTop += rect.top + anchor.offsetY - anchor.clientY; + return; + } + stage.scrollLeft = Math.max(0, stage.scrollWidth * scrollRatioX - stage.clientWidth / 2); + stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) * scrollRatioY); + }); + }); + }, []); + const zoomControls = useMemo( + () => + supportsZoom + ? { + zoom, + onZoomChange: (nextZoom, anchor) => changeZoom(nextZoom, anchor), + onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP), + onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP), + onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT) + } + : undefined, + [changeZoom, supportsZoom, zoom] + ); + const modeControls = useMemo( + () => + supportsMode + ? { + mode, + onModeChange: changeMode + } + : undefined, + [changeMode, mode, supportsMode] + ); + + if (loading && !book) return ; + + if (!book) { + return ( +
+ + +
+ + +
+
+
+ ); + } return ( -
-
- -
- {book.title} - {saving ? "Sauvegarde" : "Progression synchronisee"} -
- -
- - {book.format === "pdf" ? ( - - ) : ( - - )} -
+ void loadBook() : undefined} + controls={readerControls} + zoomControls={zoomControls} + modeControls={modeControls} + > + setReaderControls(idleControls)} + fallbackRender={(crashError, retry) => ( +
+
+

Le lecteur a rencontré une erreur.

+

La page reste ouverte. Vous pouvez réessayer, revenir au mode page par page ou retourner à la fiche du livre.

+
+
+ + + +
+ {crashError.message && ( +
+ Détail technique +
{crashError.message}
+
+ )} +
+ )} + > + {book.format === "pdf" ? ( + + ) : book.format === "cbz" || book.format === "cbr" ? ( + + ) : ( + + )} +
+
); } diff --git a/apps/web/src/pages/ReaderPageCrash.test.ts b/apps/web/src/pages/ReaderPageCrash.test.ts new file mode 100644 index 0000000..697e33b --- /dev/null +++ b/apps/web/src/pages/ReaderPageCrash.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("ReaderPage crash containment", () => { + it("keeps a reader shell or fallback mounted when the CBZ vertical reader crashes", () => { + const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8"); + + expect(source).toContain(" { + const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8"); + + expect(source).toContain("Le lecteur a rencontré une erreur."); + expect(source).toContain("Réessayer"); + expect(source).toContain("Revenir au mode page par page"); + expect(source).toContain("Retour à la fiche"); + }); +}); diff --git a/apps/web/src/pages/ReaderPageVerticalRestore.test.ts b/apps/web/src/pages/ReaderPageVerticalRestore.test.ts new file mode 100644 index 0000000..038999b --- /dev/null +++ b/apps/web/src/pages/ReaderPageVerticalRestore.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type ReaderPageElement = { + dataset: { readerPage: string }; + getBoundingClientRect: () => { top: number; bottom: number }; + scrollIntoView: ReturnType; +}; + +const runtime = vi.hoisted(() => ({ + stateIndex: 0, + refIndex: 0, + states: [] as unknown[], + frame: null as { closest: ReturnType; querySelector: ReturnType } | null, + previousMode: "vertical", + effects: [] as Array<() => void | (() => void)>, + pageElements: [] as ReaderPageElement[] +})); + +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { + ...actual, + useCallback: (callback: unknown) => callback, + useEffect: (effect: () => void | (() => void)) => { + runtime.effects.push(effect); + }, + useRef: (initial: unknown) => { + if (runtime.refIndex === 0) { + runtime.refIndex += 1; + return { current: runtime.frame }; + } + runtime.refIndex += 1; + return { current: initial }; + }, + useState: (initial: unknown) => { + const index = runtime.stateIndex; + runtime.stateIndex += 1; + return [runtime.states[index] ?? initial, vi.fn()]; + } + }; +}); + +vi.mock("../api/client", () => ({ + api: { + cbzPages: vi.fn().mockResolvedValue({ + bookId: 39, + pageCount: 144, + pages: Array.from({ length: 144 }, (_, index) => ({ page: index + 1, name: `page-${index + 1}.jpg` })) + }), + cbzPageUrl: (bookId: number, page: number) => `/books/${bookId}/pages/${page}` + } +})); + +import { CbzReader } from "../reader/CbzReader"; + +type ElementLike = { + type: unknown; + props?: Record & { children?: unknown }; +}; + +function isElementLike(value: unknown): value is ElementLike { + return Boolean(value && typeof value === "object" && "type" in value); +} + +function findElementsByType(node: unknown, type: string): ElementLike[] { + if (Array.isArray(node)) return node.flatMap((child) => findElementsByType(child, type)); + if (!isElementLike(node)) return []; + + const matches = node.type === type ? [node] : []; + return [...matches, ...findElementsByType(node.props?.children, type)]; +} + +function pageElement(page: number, top: number, height = 1000): ReaderPageElement { + return { + dataset: { readerPage: String(page) }, + getBoundingClientRect: () => ({ top, bottom: top + height }), + scrollIntoView: vi.fn() + }; +} + +function runEffects() { + for (const effect of runtime.effects) effect(); +} + +describe("ReaderPage vertical restore", () => { + beforeEach(() => { + runtime.stateIndex = 0; + runtime.refIndex = 0; + runtime.effects = []; + runtime.pageElements = Array.from({ length: 144 }, (_, index) => pageElement(index + 1, index * 1000)); + runtime.frame = { + closest: vi.fn(() => ({ + getBoundingClientRect: () => ({ top: 0, bottom: 900 }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + querySelectorAll: vi.fn(() => runtime.pageElements) + })), + querySelector: vi.fn((selector: string) => { + const match = selector.match(/\[data-reader-page="(\d+)"\]/); + return match ? runtime.pageElements[Number(match[1]) - 1] : null; + }) + }; + runtime.states = [ + { + bookId: 39, + pageCount: 144, + pages: Array.from({ length: 144 }, (_, index) => ({ page: index + 1, name: `page-${index + 1}.jpg` })) + }, + undefined, + undefined, + 0, + 0, + { width: 900, height: 900 }, + null, + {} + ]; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.stubGlobal( + "ResizeObserver", + vi.fn(() => ({ + observe: vi.fn(), + disconnect: vi.fn() + })) + ); + }); + + it("anchors page 73 of a CBZ/CBR and keeps previous pages reachable in vertical mode", () => { + let controls: { onPrevious: () => void } | undefined; + const onPageCommit = vi.fn(); + CbzReader({ + bookId: 39, + page: 73, + zoom: 100, + mode: "vertical", + onPageCommit, + onControlsChange: (nextControls) => { + controls = nextControls; + } + }); + + runEffects(); + + expect(runtime.pageElements[72].scrollIntoView).toHaveBeenCalledWith({ block: "start" }); + + runtime.stateIndex = 0; + runtime.refIndex = 0; + runtime.effects = []; + const tree = CbzReader({ + bookId: 39, + page: 73, + zoom: 100, + mode: "vertical", + onPageCommit, + onControlsChange: vi.fn() + }); + const figures = findElementsByType(tree, "figure"); + const images = findElementsByType(tree, "img"); + + expect(figures).toHaveLength(144); + expect(figures[0].props?.["data-reader-page"]).toBe(1); + expect(figures[71].props?.["data-reader-page"]).toBe(72); + expect(figures[72].props?.["data-reader-page"]).toBe(73); + expect(images[0].props?.src).toBe("/books/39/pages/1"); + expect(images[71].props?.src).toBe("/books/39/pages/72"); + expect(images[71].props?.loading).toBe("lazy"); + expect(images[71].props).not.toHaveProperty("hidden"); + + controls?.onPrevious(); + + expect(runtime.pageElements[71].scrollIntoView).toHaveBeenCalledWith({ block: "start" }); + expect(onPageCommit).toHaveBeenCalledWith(72, 144, 1, "immediate"); + }); +}); diff --git a/apps/web/src/pages/SearchPage.tsx b/apps/web/src/pages/SearchPage.tsx index e9a2f32..9fab0e7 100644 --- a/apps/web/src/pages/SearchPage.tsx +++ b/apps/web/src/pages/SearchPage.tsx @@ -1,22 +1,43 @@ import { FormEvent, useEffect, useState } from "react"; import { Search } from "lucide-react"; -import type { BookDto } from "@readabook/shared"; -import { api } from "../api/client"; +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(null); + const [books, setBooks] = useState([]); + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + + async function loadBooks(nextQuery = query) { + setLoading(true); + setError(undefined); + try { + 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 ?? []); + setError(fallback ? "Catalogue indisponible, affichage de secours." : "Recherche indisponible."); + } finally { + setLoading(false); + } + } + + const fallbackCoverLoading = hasActiveCoverWork(jobs); useEffect(() => { - api.books().then(setBooks); + void loadBooks(""); }, []); async function submit(event: FormEvent) { event.preventDefault(); - setBooks(null); - setBooks(query.trim() ? await api.search(query.trim()) : await api.books()); + await loadBooks(query); } return ( @@ -29,18 +50,29 @@ export function SearchPage() { Chercher + {error && ( +
+ {error} + +
+ )} - {!books ? ( + {loading && !books.length ? ( ) : 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 new file mode 100644 index 0000000..e8ae5c6 --- /dev/null +++ b/apps/web/src/pages/adminAutomation.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import type { AdminMetadataSourcesConfig } from "./adminAutomation"; +import { + metadataSourcesPayload, + moveSource, + normalizeMetadataSources, + providerLabels, + providerUiMessage, + providerUiStateLabel, + scheduleSummary +} from "./adminAutomation"; + +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: "comicvine", enabled: true, priority: 4, hasApiKey: false, requiresCredentials: true }, + { provider: "mangadex", enabled: false, priority: 5, hasApiKey: false } + ] +}; + +describe("admin automation helpers", () => { + it("keeps the local metadata source active and first", () => { + expect(normalizeMetadataSources(config).sources[0]).toMatchObject({ + provider: "local", + enabled: true, + priority: 0 + }); + }); + + it("excludes the local source from the update payload", () => { + expect(metadataSourcesPayload(normalizeMetadataSources(config))).toEqual({ + isbnPriorityEnabled: true, + sources: [ + { provider: "openlibrary", enabled: true, priority: 1 }, + { provider: "googlebooks", enabled: false, priority: 2 }, + { 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", "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 new file mode 100644 index 0000000..5931e94 --- /dev/null +++ b/apps/web/src/pages/adminAutomation.ts @@ -0,0 +1,132 @@ +import type { + AutomationScheduleDto, + MetadataProviderId, + MetadataSourceConfigDto, + MetadataSourcesConfigDto, + UpdateMetadataSourcesConfigDto +} from "@readabook/shared"; + +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", + 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 | 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, + sources: [ + { ...local, enabled: true, priority: 0 }, + ...external.map((source, index) => ({ ...source, priority: index + 1 })) + ] + }; +} + +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({ + provider: source.provider, + enabled: source.enabled, + priority: sources.length + 1 + }); + }); + return { + isbnPriorityEnabled: config.isbnPriorityEnabled, + sources + } as UpdateMetadataSourcesConfigDto; +} + +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; + if (index < 0 || nextIndex < 0 || nextIndex >= external.length) return sources; + + const nextExternal = [...external]; + [nextExternal[index], nextExternal[nextIndex]] = [nextExternal[nextIndex], nextExternal[index]]; + const local = sources.find((source) => source.provider === "local") ?? { provider: "local", enabled: true, priority: 0, hasApiKey: false }; + 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}.`; + return `${subject} chaque ${weekdays[schedule.dayOfWeek]} a ${schedule.time}.`; +} + +export const scheduleDays = weekdays.map((label, value) => ({ label, value })); diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx new file mode 100644 index 0000000..33bf655 --- /dev/null +++ b/apps/web/src/reader/CbzReader.tsx @@ -0,0 +1,559 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { api } from "../api/client"; +import type { CbzPagesDto } from "../api/types"; +import { clampReaderPage, orientedContainPageSize, readableViewportSize, readerPositionLabel, zoomReaderSize, type ReaderSize } from "./readerLayout"; +import { majorityVisiblePage, type ReaderMode } from "./readerScroll"; +import type { ReaderControls } from "./ReaderShell"; + +type PageCommitStrategy = "immediate" | "queued"; +type PreloadedComicImage = { + image: HTMLImageElement; + status: "loading" | "loaded" | "error"; +}; +type PreloadPriority = "adjacent" | "deep"; +type VerticalPreloadScheduler = { + active: number; + generation: number; + timer: number | null; + queuedPages: Set; + queues: Record; +}; + +const VERTICAL_ANCHOR_TOLERANCE_PX = 24; +const VERTICAL_ANCHOR_STABLE_FRAMES = 18; +const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180; +const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180; +const VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 3; +const VERTICAL_IMAGE_PRELOAD_DELAY_MS = 100; +const VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET = 90; +const VERTICAL_IMAGE_PRELOAD_DEEP_SPREAD = [0, 1, -1, 2, -2, 3, -3, 4, -4]; +const VERTICAL_IMAGE_PRELOAD_PRIORITIES: PreloadPriority[] = ["adjacent", "deep"]; + +function scheduleReaderFrame(callback: FrameRequestCallback) { + if (typeof requestAnimationFrame === "function") return requestAnimationFrame(callback); + callback(0); + return 0; +} + +function cancelReaderFrame(frameId: number) { + if (frameId && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frameId); +} + +function createVerticalPreloadScheduler(): VerticalPreloadScheduler { + return { + active: 0, + generation: 0, + timer: null, + queuedPages: new Set(), + queues: { + adjacent: [], + deep: [] + } + }; +} + +function uniqueReaderPages(pages: Array, currentPage: number, pageCount: number) { + const seen = new Set(); + return pages + .map((candidate) => (candidate ? clampReaderPage(candidate, pageCount) : null)) + .filter((candidate): candidate is number => { + if (!candidate || candidate === currentPage || seen.has(candidate)) return false; + seen.add(candidate); + return true; + }); +} + +function verticalImagePreloadPlan(currentPage: number, pageCount: number) { + const deepCenter = clampReaderPage(currentPage + VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET, pageCount); + return { + adjacent: uniqueReaderPages([currentPage - 1, currentPage + 1, currentPage + 2], currentPage, pageCount), + deep: uniqueReaderPages( + VERTICAL_IMAGE_PRELOAD_DEEP_SPREAD.map((offset) => deepCenter + offset), + currentPage, + pageCount + ) + }; +} + +export function CbzReader({ + bookId, + page, + zoom, + mode, + onPageCommit, + onControlsChange +}: { + bookId: number; + page: number; + zoom: number; + mode: ReaderMode; + onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void; + onControlsChange: (controls: ReaderControls) => void; +}) { + const frameRef = useRef(null); + const previousModeRef = useRef(null); + const pendingVerticalAnchorRef = useRef(false); + const verticalTrackingReadyRef = useRef(false); + const verticalUserScrollRef = useRef(false); + const verticalAnchorTargetPageRef = useRef(null); + const verticalAnchorFrameRef = useRef(null); + const verticalInitialSyncFrameRef = useRef(null); + const verticalInitialSyncAttemptRef = useRef(0); + const verticalInitialSyncDoneRef = useRef(false); + const verticalAnchorAttemptRef = useRef(0); + const verticalAnchorStableFramesRef = useRef(0); + const preloadedImagesRef = useRef(new Map()); + const preloadSchedulerRef = useRef(createVerticalPreloadScheduler()); + const pendingImageSizesRef = useRef>({}); + const imageSizeFlushFrameRef = useRef(null); + const [pages, setPages] = useState(null); + const [documentError, setDocumentError] = useState(); + const [pageError, setPageError] = useState(); + const [documentAttempt, setDocumentAttempt] = useState(0); + const [retryAttempt, setRetryAttempt] = useState(0); + const [viewportSize, setViewportSize] = useState(null); + const [imageSize, setImageSize] = useState(null); + const [imageSizes, setImageSizes] = useState>({}); + + useEffect(() => { + let alive = true; + setDocumentError(undefined); + setPageError(undefined); + setPages(null); + api + .cbzPages(bookId) + .then((nextPages) => { + if (!alive) return; + setPages(nextPages); + if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount, 1, "immediate"); + }) + .catch((error) => { + if (!alive) return; + setDocumentError(error instanceof Error && error.message ? `Archive indisponible: ${error.message}` : "Archive indisponible."); + }); + return () => { + alive = false; + }; + }, [bookId, documentAttempt]); + + const pageCount = pages?.pageCount ?? 1; + const currentPage = clampReaderPage(page, pageCount); + const currentName = pages?.pages.find((item) => item.page === currentPage)?.name; + const fittedSize = viewportSize && imageSize ? zoomReaderSize(orientedContainPageSize(viewportSize, imageSize), zoom) : null; + const imageStyle = fittedSize ? { width: `${fittedSize.width}px`, height: `${fittedSize.height}px` } : undefined; + const verticalImageStyle = useCallback( + (pageNumber: number) => { + const size = imageSizes[pageNumber]; + if (!viewportSize || !size) return undefined; + const fitted = zoomReaderSize(orientedContainPageSize(viewportSize, size), zoom); + return { width: `${fitted.width}px`, height: `${fitted.height}px` }; + }, + [imageSizes, viewportSize, zoom] + ); + const clearVerticalAnchorFrame = useCallback(() => { + if (verticalAnchorFrameRef.current !== null) cancelAnimationFrame(verticalAnchorFrameRef.current); + verticalAnchorFrameRef.current = null; + }, []); + const clearVerticalInitialSync = useCallback(() => { + if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current); + verticalInitialSyncFrameRef.current = null; + }, []); + const queueImageSize = useCallback((pageNumber: number, size: ReaderSize) => { + pendingImageSizesRef.current[pageNumber] = size; + if (imageSizeFlushFrameRef.current !== null) return; + imageSizeFlushFrameRef.current = scheduleReaderFrame(() => { + imageSizeFlushFrameRef.current = null; + const pending = pendingImageSizesRef.current; + pendingImageSizesRef.current = {}; + setImageSizes((current) => { + let changed = false; + const next = { ...current }; + for (const [pageKey, nextSize] of Object.entries(pending)) { + const pageNumber = Number(pageKey); + const currentSize = current[pageNumber]; + if (currentSize?.width === nextSize.width && currentSize.height === nextSize.height) continue; + next[pageNumber] = nextSize; + changed = true; + } + return changed ? next : current; + }); + }); + }, []); + const drainVerticalPreloadQueue = useCallback(() => { + const scheduler = preloadSchedulerRef.current; + scheduler.timer = null; + if (typeof Image === "undefined") return; + + const nextPage = () => { + for (const priority of VERTICAL_IMAGE_PRELOAD_PRIORITIES) { + const pageNumber = scheduler.queues[priority].shift(); + if (pageNumber) { + scheduler.queuedPages.delete(pageNumber); + return pageNumber; + } + } + return null; + }; + + while (scheduler.active < VERTICAL_IMAGE_PRELOAD_CONCURRENCY) { + const pageNumber = nextPage(); + if (!pageNumber) return; + if (preloadedImagesRef.current.has(pageNumber)) continue; + + const generation = scheduler.generation; + const image = new Image(); + preloadedImagesRef.current.set(pageNumber, { image, status: "loading" }); + scheduler.active += 1; + image.decoding = "async"; + image.loading = "eager"; + image.onload = () => { + if (generation !== scheduler.generation) return; + scheduler.active -= 1; + preloadedImagesRef.current.set(pageNumber, { image, status: "loaded" }); + if (image.naturalWidth > 0 && image.naturalHeight > 0) { + queueImageSize(pageNumber, { width: image.naturalWidth, height: image.naturalHeight }); + } + drainVerticalPreloadQueue(); + }; + image.onerror = () => { + if (generation !== scheduler.generation) return; + scheduler.active -= 1; + preloadedImagesRef.current.set(pageNumber, { image, status: "error" }); + drainVerticalPreloadQueue(); + }; + image.src = api.cbzPageUrl(bookId, pageNumber); + } + }, [bookId, queueImageSize]); + const enqueueVerticalPreload = useCallback( + (plan: Record) => { + const scheduler = preloadSchedulerRef.current; + for (const priority of VERTICAL_IMAGE_PRELOAD_PRIORITIES) { + if (priority === "deep") { + for (const pageNumber of scheduler.queues.deep) scheduler.queuedPages.delete(pageNumber); + scheduler.queues.deep = []; + } + for (const pageNumber of plan[priority]) { + if (preloadedImagesRef.current.has(pageNumber) || scheduler.queuedPages.has(pageNumber)) continue; + scheduler.queuedPages.add(pageNumber); + scheduler.queues[priority].push(pageNumber); + } + } + if (scheduler.timer !== null || scheduler.queuedPages.size === 0) return; + scheduler.timer = window.setTimeout(drainVerticalPreloadQueue, VERTICAL_IMAGE_PRELOAD_DELAY_MS); + }, + [drainVerticalPreloadQueue] + ); + const commitVisiblePage = useCallback( + (stage: HTMLElement, allowInitialCommit = false) => { + if (!pages || !verticalTrackingReadyRef.current) return; + if (stage.dataset.readerPinchActive === "true") return; + if (!allowInitialCommit && !verticalUserScrollRef.current) return; + if (allowInitialCommit && verticalInitialSyncDoneRef.current) return; + const stageRect = stage.getBoundingClientRect(); + const visiblePage = majorityVisiblePage( + Array.from(stage.querySelectorAll("[data-reader-page]")).map((element) => { + const rect = element.getBoundingClientRect(); + return { page: Number(element.dataset.readerPage), top: rect.top, bottom: rect.bottom }; + }), + stageRect.top, + stageRect.bottom + ); + if (visiblePage && visiblePage !== currentPage) { + if (allowInitialCommit) verticalInitialSyncDoneRef.current = true; + onPageCommit(clampReaderPage(visiblePage, pages.pageCount), pages.pageCount, 1, "queued"); + } + }, + [currentPage, onPageCommit, pages] + ); + const scheduleInitialVisibleSync = useCallback( + (stage: HTMLElement) => { + clearVerticalInitialSync(); + verticalInitialSyncAttemptRef.current = 0; + + const sync = () => { + verticalInitialSyncFrameRef.current = null; + if (verticalUserScrollRef.current || verticalInitialSyncDoneRef.current) return; + if (verticalTrackingReadyRef.current && stage.scrollHeight > stage.clientHeight && stage.scrollTop > Math.max(32, stage.clientHeight * 0.5)) { + commitVisiblePage(stage, true); + } + verticalInitialSyncAttemptRef.current += 1; + if (verticalInitialSyncAttemptRef.current >= VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS) return; + verticalInitialSyncFrameRef.current = requestAnimationFrame(sync); + }; + + verticalInitialSyncFrameRef.current = requestAnimationFrame(sync); + }, + [clearVerticalInitialSync, commitVisiblePage] + ); + const stabilizeVerticalAnchor = useCallback( + (targetPage: number) => { + clearVerticalAnchorFrame(); + verticalAnchorAttemptRef.current = 0; + verticalAnchorStableFramesRef.current = 0; + verticalTrackingReadyRef.current = false; + + const measure = () => { + const frame = frameRef.current; + const stage = frame?.closest(".reader-stage") as HTMLElement | null; + const target = frame?.querySelector(`[data-reader-page="${targetPage}"]`); + if (!stage || !target) return; + + target.scrollIntoView({ block: "start" }); + verticalAnchorFrameRef.current = requestAnimationFrame(() => { + verticalAnchorFrameRef.current = null; + const stageRect = stage.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const aligned = Math.abs(targetRect.top - stageRect.top) <= VERTICAL_ANCHOR_TOLERANCE_PX; + const cannotScrollFurther = stage.scrollTop + stage.clientHeight >= stage.scrollHeight - 2; + verticalAnchorStableFramesRef.current = aligned || cannotScrollFurther ? verticalAnchorStableFramesRef.current + 1 : 0; + + if (verticalAnchorStableFramesRef.current >= VERTICAL_ANCHOR_STABLE_FRAMES) { + verticalTrackingReadyRef.current = true; + commitVisiblePage(stage, true); + scheduleInitialVisibleSync(stage); + return; + } + verticalAnchorAttemptRef.current += 1; + if (verticalAnchorAttemptRef.current >= VERTICAL_ANCHOR_MAX_ATTEMPTS) return; + verticalAnchorFrameRef.current = requestAnimationFrame(measure); + }); + }; + + verticalAnchorFrameRef.current = requestAnimationFrame(measure); + }, + [clearVerticalAnchorFrame, commitVisiblePage, scheduleInitialVisibleSync] + ); + + const go = useCallback( + (nextPage: number) => { + if (!pages) return; + const target = clampReaderPage(nextPage, pages.pageCount); + setPageError(undefined); + if (mode === "vertical") { + verticalUserScrollRef.current = false; + frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); + } + onPageCommit(target, pages.pageCount, 1, "immediate"); + }, + [mode, onPageCommit, pages] + ); + + useEffect(() => { + setPageError(undefined); + setImageSize(null); + }, [bookId, currentPage, retryAttempt]); + + useEffect(() => { + setImageSizes({}); + pendingImageSizesRef.current = {}; + preloadedImagesRef.current.clear(); + const scheduler = preloadSchedulerRef.current; + if (scheduler.timer !== null) window.clearTimeout(scheduler.timer); + preloadSchedulerRef.current = createVerticalPreloadScheduler(); + preloadSchedulerRef.current.generation = scheduler.generation + 1; + }, [bookId, retryAttempt]); + + useEffect(() => { + if (mode !== "vertical" || !pages || typeof Image === "undefined" || !verticalUserScrollRef.current) return; + enqueueVerticalPreload(verticalImagePreloadPlan(currentPage, pages.pageCount)); + }, [currentPage, enqueueVerticalPreload, mode, pages, retryAttempt]); + + useEffect(() => { + const frame = frameRef.current; + const stage = frame?.closest(".reader-stage") as HTMLElement | null; + const observedElement = stage ?? frame; + if (!observedElement) return; + const updateSize = () => { + const rect = observedElement.getBoundingClientRect(); + const nextSize = readableViewportSize({ width: rect.width, height: rect.height }); + if (nextSize) setViewportSize(nextSize); + }; + updateSize(); + const frameId = requestAnimationFrame(updateSize); + const observer = new ResizeObserver(updateSize); + observer.observe(observedElement); + return () => { + cancelAnimationFrame(frameId); + observer.disconnect(); + }; + }, []); + + useEffect(() => { + onControlsChange({ + canPrevious: Boolean(pages) && !documentError && !pageError && currentPage > 1, + canNext: Boolean(pages) && !documentError && !pageError && currentPage < pageCount, + positionLabel: pages ? readerPositionLabel(currentPage, pageCount) : "Chargement", + onPrevious: () => go(currentPage - 1), + onNext: () => go(currentPage + 1) + }); + }, [currentPage, documentError, go, onControlsChange, pageCount, pageError, pages]); + + useEffect(() => { + if (mode !== "vertical") { + previousModeRef.current = mode; + pendingVerticalAnchorRef.current = false; + verticalTrackingReadyRef.current = false; + verticalUserScrollRef.current = false; + verticalAnchorTargetPageRef.current = null; + verticalInitialSyncDoneRef.current = false; + clearVerticalAnchorFrame(); + clearVerticalInitialSync(); + return; + } + const enteringVertical = previousModeRef.current !== "vertical"; + if (enteringVertical || (!verticalUserScrollRef.current && verticalAnchorTargetPageRef.current !== currentPage)) { + pendingVerticalAnchorRef.current = true; + verticalTrackingReadyRef.current = false; + verticalUserScrollRef.current = false; + if (enteringVertical) verticalInitialSyncDoneRef.current = false; + clearVerticalAnchorFrame(); + clearVerticalInitialSync(); + } + if (pendingVerticalAnchorRef.current) { + const target = frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`); + if (!target) { + previousModeRef.current = mode; + return; + } + pendingVerticalAnchorRef.current = false; + verticalAnchorTargetPageRef.current = currentPage; + stabilizeVerticalAnchor(currentPage); + } + previousModeRef.current = mode; + }, [clearVerticalAnchorFrame, clearVerticalInitialSync, currentPage, mode, pages, stabilizeVerticalAnchor]); + + useEffect(() => { + if (mode !== "vertical" || !pages) return; + const stage = frameRef.current?.closest(".reader-stage") as HTMLElement | null; + if (!stage) return; + let frameId = 0; + const markUserScroll = () => { + if (stage.dataset.readerPinchActive === "true") return; + verticalUserScrollRef.current = true; + }; + const markUserScrollKey = (event: KeyboardEvent) => { + if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"].includes(event.key)) markUserScroll(); + }; + const keyTarget = typeof window === "undefined" ? null : window; + const updateVisiblePage = () => { + frameId = 0; + commitVisiblePage(stage); + }; + const onScroll = () => { + if (stage.dataset.readerPinchActive === "true") return; + if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true; + if (frameId) return; + frameId = requestAnimationFrame(updateVisiblePage); + }; + stage.addEventListener("wheel", markUserScroll, { passive: true }); + stage.addEventListener("touchmove", markUserScroll, { passive: true }); + stage.addEventListener("pointerdown", markUserScroll, { passive: true }); + keyTarget?.addEventListener("keydown", markUserScrollKey); + stage.addEventListener("scroll", onScroll, { passive: true }); + scheduleInitialVisibleSync(stage); + updateVisiblePage(); + return () => { + if (frameId) cancelAnimationFrame(frameId); + stage.removeEventListener("wheel", markUserScroll); + stage.removeEventListener("touchmove", markUserScroll); + stage.removeEventListener("pointerdown", markUserScroll); + keyTarget?.removeEventListener("keydown", markUserScrollKey); + stage.removeEventListener("scroll", onScroll); + }; + }, [commitVisiblePage, mode, pages, scheduleInitialVisibleSync]); + + useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]); + useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]); + useEffect( + () => () => { + if (imageSizeFlushFrameRef.current !== null) cancelReaderFrame(imageSizeFlushFrameRef.current); + }, + [] + ); + + if (documentError) { + return ( +
+
+ {documentError} + +
+
+ ); + } + + if (!pages) { + return ( +
+
+ Chargement de l'archive +
+
+ ); + } + + if (pageError) { + return ( +
+
+ {pageError} + +
+
+ ); + } + + return ( +
+ {mode === "vertical" ? ( +
+ {pages.pages.map((item) => ( +
+ {!imageSizes[item.page] && ( +
+ Page {item.page} +
+ )} + {item.name} { + const { naturalWidth, naturalHeight } = event.currentTarget; + preloadedImagesRef.current.set(item.page, { image: event.currentTarget, status: "loaded" }); + queueImageSize(item.page, { width: naturalWidth, height: naturalHeight }); + }} + onError={() => setPageError(`Page ${item.page} indisponible.`)} + /> +
+ ))} +
+ ) : ( +
+ {!imageSize && ( +
+ Page {currentPage} +
+ )} + { + setImageSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight }); + }} + onError={() => setPageError(`Page ${currentPage} indisponible.`)} + /> +
+ )} +
+ ); +} diff --git a/apps/web/src/reader/CbzReaderNaturalWidth.test.tsx b/apps/web/src/reader/CbzReaderNaturalWidth.test.tsx new file mode 100644 index 0000000..dece013 --- /dev/null +++ b/apps/web/src/reader/CbzReaderNaturalWidth.test.tsx @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const hookState = vi.hoisted(() => ({ + stateIndex: 0, + states: [] as unknown[], + updates: [] as unknown[][] +})); + +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { + ...actual, + useCallback: (callback: unknown) => callback, + useEffect: () => undefined, + useRef: (current: unknown) => ({ current }), + useState: (initial: unknown) => { + const index = hookState.stateIndex; + hookState.stateIndex += 1; + hookState.updates[index] = []; + return [ + hookState.states[index] ?? initial, + (next: unknown) => { + hookState.updates[index].push(next); + } + ]; + } + }; +}); + +vi.mock("../api/client", () => ({ + api: { + cbzPages: vi.fn().mockResolvedValue({ bookId: 2, pageCount: 1, pages: [{ page: 1, name: "page-1.jpg" }] }), + cbzPageUrl: (bookId: number, page: number) => `/books/${bookId}/pages/${page}` + } +})); + +import { CbzReader } from "./CbzReader"; + +type ElementLike = { + type: unknown; + props?: Record & { children?: unknown }; +}; + +function isElementLike(value: unknown): value is ElementLike { + return Boolean(value && typeof value === "object" && "type" in value); +} + +function findElementsByType(node: unknown, type: string): ElementLike[] { + if (Array.isArray(node)) return node.flatMap((child) => findElementsByType(child, type)); + if (!isElementLike(node)) return []; + + const matches = node.type === type ? [node] : []; + return [...matches, ...findElementsByType(node.props?.children, type)]; +} + +describe("CbzReader vertical image load", () => { + beforeEach(() => { + hookState.stateIndex = 0; + hookState.updates = []; + hookState.states = [ + { bookId: 2, pageCount: 1, pages: [{ page: 1, name: "page-1.jpg" }] }, + undefined, + undefined, + 0, + 0, + { width: 800, height: 1200 }, + null, + {} + ]; + }); + + it("records vertical image dimensions before React clears the load event target", () => { + const tree = CbzReader({ + bookId: 2, + page: 1, + zoom: 100, + mode: "vertical", + onPageCommit: vi.fn(), + onControlsChange: vi.fn() + }); + const image = findElementsByType(tree, "img")[0]; + const onLoad = image.props?.onLoad as (event: { currentTarget: { naturalWidth: number; naturalHeight: number } | null }) => void; + const event: { currentTarget: { naturalWidth: number; naturalHeight: number } | null } = { currentTarget: { naturalWidth: 480, naturalHeight: 960 } }; + + onLoad(event); + event.currentTarget = null; + + let nextSizes: unknown; + expect(() => { + nextSizes = (hookState.updates[7][0] as (current: Record) => unknown)({}); + }).not.toThrow(); + expect(nextSizes).toEqual({ 1: { width: 480, height: 960 } }); + }); +}); diff --git a/apps/web/src/reader/EpubReader.tsx b/apps/web/src/reader/EpubReader.tsx index 8fba5d0..c764313 100644 --- a/apps/web/src/reader/EpubReader.tsx +++ b/apps/web/src/reader/EpubReader.tsx @@ -1,37 +1,156 @@ import { useEffect, useRef, useState } from "react"; +import { ReaderError, readerErrorMessage } from "./ReaderError"; +import type { ReaderControls } from "./ReaderShell"; +import type { ReaderMode } from "../api/types"; -type FoliateModule = { - EPUB?: unknown; - default?: unknown; +type FoliateLocation = { + cfi?: string; + fraction?: number; + current?: number; + total?: number; }; -export function EpubReader({ url, locator, onLocatorChange }: { url: string; locator?: string; onLocatorChange: (locator: string, percent: number) => void }) { +type FoliateView = HTMLElement & { + open(input: File | Blob | string): Promise; + close(): void; + goLeft(): Promise; + goRight(): Promise; + goTo(target: string): Promise; + next(): Promise; + lastLocation?: FoliateLocation; +}; + +export function epubFileName(url: string): string { + try { + const base = globalThis.location?.href ?? "http://readabook.local/"; + const pathname = new URL(url, base).pathname; + const name = pathname.split("/").filter(Boolean).at(-1); + return name && name.includes(".") ? name : "book.epub"; + } catch { + return "book.epub"; + } +} + +function locationPercent(location: FoliateLocation): number { + if (typeof location.fraction === "number") return Math.max(0, Math.min(100, location.fraction * 100)); + if (typeof location.current === "number" && typeof location.total === "number" && location.total > 0) { + return Math.max(0, Math.min(100, (location.current / location.total) * 100)); + } + return 1; +} + +export function EpubReader({ + url, + locator, + backHref, + mode, + onLocatorChange, + onControlsChange +}: { + url: string; + locator?: string; + backHref: string; + mode: ReaderMode; + onLocatorChange: (locator: string, percent: number) => void; + onControlsChange: (controls: ReaderControls) => void; +}) { const hostRef = useRef(null); - const [status, setStatus] = useState("Ouverture EPUB"); + const viewRef = useRef(null); + const locatorRef = useRef(locator); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + onControlsChange({ + canPrevious: !loading && !error, + canNext: !loading && !error, + positionLabel: loading ? "Ouverture EPUB" : "Lecture integree", + onPrevious: () => void viewRef.current?.goLeft(), + onNext: () => void viewRef.current?.goRight() + }); + }, [error, loading, onControlsChange]); + + useEffect(() => { + locatorRef.current = locator; + }, [locator]); useEffect(() => { let cancelled = false; + let view: FoliateView | null = null; + async function mount() { try { - const module = (await import("foliate-js/epub.js")) as FoliateModule; + setLoading(true); + setError(undefined); + await import("foliate-js/view.js"); if (cancelled || !hostRef.current) return; - hostRef.current.dataset.engine = module.EPUB || module.default ? "foliate-js" : "fallback"; - setStatus("EPUB pret"); - onLocatorChange(locator ?? "epub:start", locator ? 35 : 1); - } catch { - setStatus("Apercu EPUB indisponible dans ce navigateur"); + + const response = await fetch(url, { credentials: "include" }); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + const blob = await response.blob(); + if (cancelled || !hostRef.current) return; + + view = document.createElement("foliate-view") as FoliateView; + view.classList.add("epub-view"); + view.addEventListener("relocate", (event) => { + const location = (event as CustomEvent).detail; + if (location?.cfi) onLocatorChange(location.cfi, locationPercent(location)); + }); + hostRef.current.replaceChildren(view); + viewRef.current = view; + + const file = new File([blob], epubFileName(url), { type: blob.type || "application/epub+zip" }); + await view.open(file); + if (cancelled) return; + if (locatorRef.current) await view.goTo(locatorRef.current); + else await view.next(); + setLoading(false); + } catch (mountError) { + if (!cancelled) { + setError(readerErrorMessage(mountError, "EPUB indisponible")); + setLoading(false); + } } } - mount(); + + void mount(); return () => { cancelled = true; + view?.close?.(); + view?.remove(); + if (viewRef.current === view) viewRef.current = null; }; - }, [locator, onLocatorChange, url]); + }, [attempt, onLocatorChange, url]); + + useEffect(() => { + viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical"); + viewRef.current?.classList.toggle("epub-view-horizontal", mode === "horizontal"); + }, [mode]); + + if (error) { + return ( +
+ setAttempt((value) => value + 1)} + /> +
+ ); + } return ( -
-