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..5f72e0f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist *.sqlite-* *.tsbuildinfo data/storage +Books/ coverage .pnpm-store .ideai/ diff --git a/README.md b/README.md index bdfc20f..9466674 100644 --- a/README.md +++ b/README.md @@ -94,12 +94,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 +136,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/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..da1de95 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,5 +1,6 @@ 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"; @@ -8,7 +9,7 @@ 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, ScannerModule, AutomationModule], controllers: [HealthController] }) export class AppModule {} 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 fe2cbe9..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"; @@ -40,10 +40,17 @@ export class BooksController { } @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.service.ts b/apps/api/src/books/books.service.ts index 26d2ad7..43fd7da 100644 --- a/apps/api/src/books/books.service.ts +++ b/apps/api/src/books/books.service.ts @@ -1,5 +1,5 @@ -import { BadRequestException, 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"; @@ -53,12 +53,22 @@ export class BooksService { return book; } - streamFile(id: number) { + streamFile(id: number, range?: string) { const book = this.get(id); if (!existsSync(book.filePath)) { throw new NotFoundException("Book file not found on disk"); } - return { book, stream: createReadStream(book.filePath) }; + 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) { @@ -108,6 +118,38 @@ export class BooksService { } } +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"; @@ -125,6 +167,7 @@ function mapBookRow(row: Record) { author: nullable(row.author), description: nullable(row.description), isbn: nullable(row.isbn), + isbn13: nullable(row.isbn13), language: nullable(row.language), publisher: nullable(row.publisher), publishedDate: nullable(row.published_date), diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index fd55b3b..cc09fd9 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -11,6 +11,7 @@ export type AppConfig = { cookieName: string; cookieSecure: boolean; openLibraryEnabled: boolean; + libraryPathAliases: Array<{ from: string; to: string }>; initialAdminEmail: string; initialAdminPassword: string; initialAdminPasswordIsDefault: boolean; @@ -36,8 +37,25 @@ export function loadConfig(): AppConfig { cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session", cookieSecure: process.env.COOKIE_SECURE === "true", 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..0f0c6cb --- /dev/null +++ b/apps/api/src/database/database.service.test.ts @@ -0,0 +1,118 @@ +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"; + +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); + 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 + ); + `); + 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 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(bookIndexes.map((index) => index.name)).toContain("books_isbn13_idx"); + expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf"]); + expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 }); + + database.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 9c26a7b..62acb45 100644 --- a/apps/api/src/database/database.service.ts +++ b/apps/api/src/database/database.service.ts @@ -56,6 +56,8 @@ export class DatabaseService implements OnModuleDestroy { author TEXT, description TEXT, isbn TEXT, + isbn13 TEXT, + identifiers_json TEXT, language TEXT, publisher TEXT, published_date TEXT, @@ -89,6 +91,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')), + 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, @@ -120,6 +142,10 @@ export class DatabaseService implements OnModuleDestroy { END; `); this.ensureBooksSupportsComicArchives(); + this.ensureBooksMetadataColumns(); + this.ensureMetadataSourceConfigColumns(); + this.ensureAutomationSettingsColumns(); + this.ensureMetadataDefaults(); this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')"); } @@ -146,6 +172,8 @@ export class DatabaseService implements OnModuleDestroy { author TEXT, description TEXT, isbn TEXT, + isbn13 TEXT, + identifiers_json TEXT, language TEXT, publisher TEXT, published_date TEXT, @@ -158,11 +186,11 @@ export class DatabaseService implements OnModuleDestroy { updated_at TEXT NOT NULL ); INSERT INTO books ( - id, library_id, title, author, description, isbn, language, publisher, published_date, + id, library_id, title, author, description, isbn, isbn13, identifiers_json, language, publisher, published_date, format, file_path, cover_path, file_size, file_mtime, created_at, updated_at ) SELECT - id, library_id, title, author, description, isbn, language, publisher, published_date, + id, library_id, title, author, description, isbn, NULL, NULL, language, publisher, published_date, format, file_path, cover_path, file_size, file_mtime, created_at, updated_at FROM books_legacy_format; DROP TABLE books_legacy_format; @@ -174,6 +202,7 @@ export class DatabaseService implements OnModuleDestroy { 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) @@ -193,4 +222,104 @@ export class DatabaseService implements OnModuleDestroy { 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"); + } + this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13)"); + } + + 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 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); + + 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 cd04364..87800de 100644 --- a/apps/api/src/database/schema.ts +++ b/apps/api/src/database/schema.ts @@ -34,6 +34,8 @@ export const books = sqliteTable( author: text("author"), description: text("description"), isbn: text("isbn"), + isbn13: text("isbn13"), + identifiersJson: text("identifiers_json"), language: text("language"), publisher: text("publisher"), publishedDate: text("published_date"), @@ -75,3 +77,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"] }).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/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..5c26d5e --- /dev/null +++ b/apps/api/src/metadata/adapters/bnf.provider.ts @@ -0,0 +1,68 @@ +import { Injectable } from "@nestjs/common"; +import { XMLParser } from "fast-xml-parser"; +import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js"; +import { toIsbn13 } from "../use-cases/extract-identifiers.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; + const query = isbn + ? `bib.isbn all "${isbn}"` + : `bib.title all "${lookup.title.replace(/"/g, " ")}"`; + 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", "1"); + + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!response.ok) return null; + const parsed = parser.parse(await response.text()); + const record = parsed?.searchRetrieveResponse?.records?.record?.recordData?.record; + if (!record) return null; + 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, lookup.identifiers.isbn13), + language: subfield(fields, "101", "a"), + publisher: subfield(fields, "210", "c") ?? subfield(fields, "214", "c"), + publishedDate: cleanDate(subfield(fields, "210", "d") ?? subfield(fields, "214", "d")) + }; + } +} + +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) => toIsbn13(candidate) === expectedIsbn13) ?? values.find((candidate) => 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/google-books.provider.ts b/apps/api/src/metadata/adapters/google-books.provider.ts new file mode 100644 index 0000000..3b9090b --- /dev/null +++ b/apps/api/src/metadata/adapters/google-books.provider.ts @@ -0,0 +1,53 @@ +import { Injectable } from "@nestjs/common"; +import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js"; +import { toIsbn13 } from "../use-cases/extract-identifiers.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; + const query = isbn + ? `isbn:${isbn}` + : `intitle:${lookup.title}${lookup.author ? `+inauthor:${lookup.author}` : ""}`; + const url = new URL("https://www.googleapis.com/books/v1/volumes"); + url.searchParams.set("q", query); + url.searchParams.set("maxResults", "1"); + url.searchParams.set("printType", "books"); + if (config.apiKey) url.searchParams.set("key", config.apiKey); + + const response = await fetch(url, { signal: AbortSignal.timeout(4000) }); + if (!response.ok) return null; + const data = (await response.json()) as { items?: Array<{ volumeInfo?: Record }> }; + const info = data.items?.[0]?.volumeInfo; + if (!info) return null; + return { + title: stringValue(info.title) ?? undefined, + author: arrayJoin(info.authors), + description: stringValue(info.description), + language: stringValue(info.language), + publisher: stringValue(info.publisher), + publishedDate: stringValue(info.publishedDate), + isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, lookup.identifiers.isbn13) + }; + } +} + +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) => 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); +} 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..5223716 --- /dev/null +++ b/apps/api/src/metadata/adapters/local.provider.ts @@ -0,0 +1,16 @@ +import { Injectable } from "@nestjs/common"; +import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.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 + }; + } +} 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..242a05b --- /dev/null +++ b/apps/api/src/metadata/adapters/open-library.provider.ts @@ -0,0 +1,100 @@ +import { Injectable } from "@nestjs/common"; +import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js"; +import { toIsbn13 } from "../use-cases/extract-identifiers.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); + } + const query = `title:${lookup.title}${lookup.author ? ` author:${lookup.author}` : ""}`; + const url = new URL("https://openlibrary.org/search.json"); + url.searchParams.set("q", query); + url.searchParams.set("limit", "1"); + const response = await fetch(url, { + headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" }, + signal: AbortSignal.timeout(4000) + }); + if (!response.ok) return null; + const data = (await response.json()) as { docs?: Array> }; + const doc = data.docs?.[0]; + if (!doc) return null; + return { + title: stringValue(doc.title) ?? undefined, + author: arrayJoin(doc.author_name), + language: firstArrayValue(doc.language), + publisher: firstArrayValue(doc.publisher), + publishedDate: String(doc.first_publish_year ?? "") || null, + isbn: bestIsbn(doc.isbn, lookup.identifiers.isbn13) + }; + } + + private async lookupIsbn(isbn: string, expectedIsbn13: string | null): Promise { + const response = await fetch(`https://openlibrary.org/isbn/${encodeURIComponent(isbn)}.json`, { + headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" }, + signal: AbortSignal.timeout(4000) + }); + if (!response.ok) return null; + const edition = (await response.json()) as Record; + 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: stringValue(edition.publish_date) + }; + } + + private async lookupAuthorName(value: unknown): Promise { + const key = (Array.isArray(value) ? value[0] : undefined)?.key; + if (typeof key !== "string") return null; + const response = await fetch(`https://openlibrary.org${key}.json`, { + headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" }, + signal: AbortSignal.timeout(3000) + }); + if (!response.ok) return null; + 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) => toIsbn13(candidate) === expectedIsbn13) ?? values.find((candidate) => 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; +} 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..56b1ad4 --- /dev/null +++ b/apps/api/src/metadata/extract-identifiers.test.ts @@ -0,0 +1,31 @@ +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(); + }); + + 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/metadata-providers.test.ts b/apps/api/src/metadata/metadata-providers.test.ts new file mode 100644 index 0000000..05e51f1 --- /dev/null +++ b/apps/api/src/metadata/metadata-providers.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BnfProvider } from "./adapters/bnf.provider.js"; +import { GoogleBooksProvider } from "./adapters/google-books.provider.js"; +import { OpenLibraryProvider } from "./adapters/open-library.provider.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"] } +}; + +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("treats Google Books quota exhaustion as a non-blocking miss", async () => { + const fetchMock = vi.fn(async () => jsonResponse({ error: { code: 429, status: "RESOURCE_EXHAUSTED" } }, 429)); + vi.stubGlobal("fetch", fetchMock); + + const result = await new GoogleBooksProvider().lookup(lookup, { ...config, provider: "googlebooks" }); + + expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toContain("q=isbn%3A9782070612376"); + expect(result).toBeNull(); + }); + + 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" + }); + }); +}); + +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..d15002a --- /dev/null +++ b/apps/api/src/metadata/metadata.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { DatabaseModule } from "../database/database.module.js"; +import { BnfProvider } from "./adapters/bnf.provider.js"; +import { GoogleBooksProvider } from "./adapters/google-books.provider.js"; +import { LocalMetadataProvider } from "./adapters/local.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], + exports: [MetadataService] +}) +export class MetadataModule {} diff --git a/apps/api/src/metadata/metadata.service.ts b/apps/api/src/metadata/metadata.service.ts new file mode 100644 index 0000000..b27001d --- /dev/null +++ b/apps/api/src/metadata/metadata.service.ts @@ -0,0 +1,167 @@ +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { eq } from "drizzle-orm"; +import { + MetadataSourcesConfigDto, + UpdateMetadataSourcesConfigDto +} from "@readabook/shared"; +import { DatabaseService } from "../database/database.service.js"; +import { automationSettings, books, metadataSourceConfig } from "../database/schema.js"; +import { BookMetadata } from "../scanner/metadata.js"; +import { BnfProvider } from "./adapters/bnf.provider.js"; +import { GoogleBooksProvider } from "./adapters/google-books.provider.js"; +import { LocalMetadataProvider } from "./adapters/local.provider.js"; +import { OpenLibraryProvider } from "./adapters/open-library.provider.js"; +import { MetadataMatch, MetadataProvider, MetadataProviderConfig } from "./metadata.types.js"; +import { ExtractIdentifiers, toIsbn13 } from "./use-cases/extract-identifiers.js"; +import { ResolveProviderChain } from "./use-cases/resolve-provider-chain.js"; + +@Injectable() +export class MetadataService { + private readonly extractIdentifiers = new ExtractIdentifiers(); + private readonly resolveProviderChain: ResolveProviderChain; + + constructor( + private readonly database: DatabaseService, + local: LocalMetadataProvider, + openLibrary: OpenLibraryProvider, + googleBooks: GoogleBooksProvider, + bnf: BnfProvider + ) { + this.resolveProviderChain = new ResolveProviderChain([local, openLibrary, googleBooks, bnf]); + } + + 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 { + const identifiers = this.extractIdentifiers.fromMetadataAndFile(localMetadata, filePath); + const configs = this.getProviderConfigs(); + const chain = options.remote + ? this.resolveProviderChain.resolve(configs) + : this.resolveProviderChain.resolve(configs).filter((entry) => entry.provider.id === "local"); + let merged: BookMetadata = { ...localMetadata }; + + for (const { provider, config } of chain) { + try { + const match = await provider.lookup( + { + title: merged.title, + author: merged.author, + filePath, + identifiers + }, + config + ); + if (match) merged = mergeMetadata(merged, match); + } catch { + // Provider failures must not block local ingestion. + } + } + + const isbn13 = identifiers.isbn13 ?? (merged.isbn ? toIsbn13(merged.isbn) : null); + return { + ...merged, + isbn: merged.isbn ?? isbn13 ?? identifiers.isbn10, + isbn13, + identifiersJson: JSON.stringify(identifiers) + }; + } + + async enrichBook(bookId: number): Promise { + const book = this.database.db.select().from(books).where(eq(books.id, bookId)).get(); + if (!book) throw new NotFoundException("Book not found"); + const metadata: BookMetadata = { + title: book.title, + author: book.author, + description: book.description, + isbn: book.isbn, + language: book.language, + publisher: book.publisher, + publishedDate: book.publishedDate, + coverPath: book.coverPath + }; + const enriched = await this.enrichMetadata(metadata, book.filePath, { remote: true }); + return this.database.db + .update(books) + .set({ + title: enriched.title, + author: enriched.author, + description: enriched.description, + isbn: enriched.isbn, + isbn13: enriched.isbn13, + identifiersJson: enriched.identifiersJson, + language: enriched.language, + publisher: enriched.publisher, + publishedDate: enriched.publishedDate, + coverPath: enriched.coverPath, + updatedAt: this.database.now() + }) + .where(eq(books.id, book.id)) + .returning() + .get(); + } + + 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()!; + } +} + +function mergeMetadata(current: BookMetadata, next: MetadataMatch): BookMetadata { + return { + title: next.title ?? current.title, + author: current.author ?? next.author ?? null, + description: current.description ?? next.description ?? null, + isbn: current.isbn ?? next.isbn ?? null, + language: current.language ?? next.language ?? null, + publisher: current.publisher ?? next.publisher ?? null, + publishedDate: current.publishedDate ?? next.publishedDate ?? null, + coverPath: current.coverPath ?? next.coverPath ?? null + }; +} diff --git a/apps/api/src/metadata/metadata.types.ts b/apps/api/src/metadata/metadata.types.ts new file mode 100644 index 0000000..0e1521c --- /dev/null +++ b/apps/api/src/metadata/metadata.types.ts @@ -0,0 +1,32 @@ +import { BookMetadata } from "../scanner/metadata.js"; + +export type MetadataProviderId = "local" | "openlibrary" | "googlebooks" | "bnf"; + +export type BookIdentifiers = { + isbn10: string | null; + isbn13: string | null; + candidates: string[]; +}; + +export type MetadataLookup = { + title: string; + author: string | null; + filePath: string; + identifiers: BookIdentifiers; +}; + +export type MetadataMatch = Partial & { + identifiers?: 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; +} 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..b26176b --- /dev/null +++ b/apps/api/src/metadata/resolve-provider-chain.test.ts @@ -0,0 +1,20 @@ +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 +}); + +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..a1cec41 --- /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 && 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/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/scanner/metadata.ts b/apps/api/src/scanner/metadata.ts index 78196cd..ea232f1 100644 --- a/apps/api/src/scanner/metadata.ts +++ b/apps/api/src/scanner/metadata.ts @@ -3,7 +3,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, extname, join } from "node:path"; import AdmZip from "adm-zip"; import { XMLParser } from "fast-xml-parser"; -import { listCbrImageEntries, readCbrPage } from "../common/cbr.js"; +import { listCbrImageEntries } from "../common/cbr.js"; import { listCbzImageEntries } from "../common/cbz.js"; export type BookMetadata = { @@ -97,16 +97,10 @@ function extractCbzMetadata(filePath: string, storageDir: string): BookMetadata } async function extractCbrMetadata(filePath: string, storageDir: string): Promise { - const firstPage = (await listCbrImageEntries(filePath))[0]; - const page = await readCbrPage(filePath, 1, storageDir); - const extension = extname(firstPage.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, page.data); + await listCbrImageEntries(filePath); return { ...fallbackMetadata(filePath), - coverPath: target + coverPath: 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..2238eee --- /dev/null +++ b/apps/api/src/scanner/scanner.service.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { scanDigest } from "./scanner.service.js"; + +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); + }); +}); diff --git a/apps/api/src/scanner/scanner.service.ts b/apps/api/src/scanner/scanner.service.ts index 90924fe..e8aaeaf 100644 --- a/apps/api/src/scanner/scanner.service.ts +++ b/apps/api/src/scanner/scanner.service.ts @@ -1,19 +1,19 @@ 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 } from "../database/schema.js"; import { JobsService } from "../jobs/jobs.service.js"; +import { MetadataService } from "../metadata/metadata.service.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,36 +28,87 @@ 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); + 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); + } + } + 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; + for (const row of rows) { + await this.metadata.enrichBook(row.id); count += 1; } - this.jobs.markSucceeded(jobId, `Scanned ${count} file(s)`); + this.jobs.markSucceeded(jobId, `Enriched ${count} book(s)`); } private async ingestFile(libraryId: number, filePath: string): Promise { const stats = statSync(filePath); - let metadata = await 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 localMetadata = await extractMetadata(filePath, this.database.config.storageDir); const now = this.database.now(); const format = bookFormatFromPath(filePath); const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get(); + const shouldRemoteEnrich = !existing ? this.shouldAutoEnrichNewBooks() : true; + const metadata = await this.metadata.enrichMetadata(localMetadata, filePath, { remote: shouldRemoteEnrich }); const values = { libraryId, title: metadata.title, author: metadata.author, description: metadata.description, isbn: metadata.isbn, + isbn13: metadata.isbn13, + identifiersJson: metadata.identifiersJson, language: metadata.language, publisher: metadata.publisher, publishedDate: metadata.publishedDate, @@ -73,6 +124,77 @@ export class ScannerService { ? 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(); } + + private ingestIncompleteFile(libraryId: number, filePath: string): void { + const stats = statSync(filePath); + const now = this.database.now(); + const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get(); + const values = { + libraryId, + title: basename(filePath, extname(filePath)), + author: null, + description: null, + isbn: null, + isbn13: null, + identifiersJson: JSON.stringify({ candidates: [], isbn10: null, isbn13: null }), + language: null, + publisher: null, + publishedDate: null, + format: bookFormatFromPath(filePath), + filePath, + coverPath: null, + 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(); + } + + 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 + ); + } +} + +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}`; +} + +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 { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index be34fb9..0831671 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,6 +3,7 @@ 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"; @@ -31,6 +32,8 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi ) : route.name === "me" ? ( + ) : route.name === "admin" && route.section === "automation" ? ( + ) : ( ); diff --git a/apps/web/src/api/client.test.ts b/apps/web/src/api/client.test.ts index 1f8ccf8..80ddfdc 100644 --- a/apps/web/src/api/client.test.ts +++ b/apps/web/src/api/client.test.ts @@ -30,4 +30,78 @@ describe("api fallback helpers", () => { 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("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 7420756..baeb744 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -2,17 +2,30 @@ 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 { + mockAutomationSettings, + mockBooks, + mockContinue, + mockJobs, + mockLibraries, + mockMetadataSources, + mockProgress, + mockUser +} from "./mockData"; import type { CbzPagesDto, ContinueItem, Session } from "./types"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; @@ -172,20 +185,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 0db5833..23acb66 100644 --- a/apps/web/src/api/mockData.ts +++ b/apps/web/src/api/mockData.ts @@ -1,4 +1,4 @@ -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(); @@ -24,6 +24,7 @@ export const mockBooks: BookDto[] = [ author: "M. Valrose", description: "Fragments, croquis et notes rassemblees autour d'automates introuvables.", isbn: null, + isbn13: null, language: "fr", publisher: "Cabinet ReadaBook", publishedDate: "1908", @@ -42,6 +43,7 @@ export const mockBooks: BookDto[] = [ 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", @@ -60,6 +62,7 @@ export const mockBooks: BookDto[] = [ author: "A. Muze", description: "Un recit graphique indexe comme archive CBZ.", isbn: null, + isbn13: null, language: "fr", publisher: "ReadaBook", publishedDate: "1934", @@ -78,6 +81,7 @@ export const mockBooks: BookDto[] = [ 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", @@ -106,3 +110,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/layout/AppShell.tsx b/apps/web/src/layout/AppShell.tsx index 0b84200..4f695bf 100644 --- a/apps/web/src/layout/AppShell.tsx +++ b/apps/web/src/layout/AppShell.tsx @@ -1,4 +1,4 @@ -import { Archive, Home, Search, Settings, UserRound } from "lucide-react"; +import { Archive, Home, Search, Settings, SlidersHorizontal, UserRound } from "lucide-react"; import type { ReactNode } from "react"; import type { Session } from "../api/types"; import { navigate } from "../router"; @@ -7,6 +7,7 @@ const navItems = [ { href: "/home", label: "Accueil", icon: Home }, { href: "/search", label: "Recherche", icon: Search }, { href: "/admin/libraries", label: "Admin", icon: Settings }, + { href: "/admin/automation", label: "Automatisation", icon: SlidersHorizontal }, { href: "/me", label: "Profil", icon: UserRound } ]; diff --git a/apps/web/src/pages/AdminAutomationPage.tsx b/apps/web/src/pages/AdminAutomationPage.tsx new file mode 100644 index 0000000..a570abf --- /dev/null +++ b/apps/web/src/pages/AdminAutomationPage.tsx @@ -0,0 +1,506 @@ +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { ArrowDown, ArrowUp, Play, Save } from "lucide-react"; +import type { + AutomationFrequency, + AutomationScheduleDto, + AutomationSettingsDto, + MetadataProviderId, + MetadataSourcesConfigDto +} from "@readabook/shared"; +import { api, getApiFallback } from "../api/client"; +import { ErrorRibbon, LoadingState, Panel } from "../components/ui"; +import { + metadataSourcesPayload, + moveSource, + normalizeMetadataSources, + providerLabels, + scheduleDays, + scheduleSummary +} from "./adminAutomation"; + +type AdminAutomationTab = "sources" | "automation"; +type ApiState = { + initial: T | null; + draft: T | null; + loading: boolean; + saving: boolean; + error?: string; + success?: string; +}; + +const defaultMetadataConfig: MetadataSourcesConfigDto = { + isbnPriorityEnabled: true, + sources: [ + { provider: "local", enabled: true, priority: 0, hasApiKey: false }, + { provider: "openlibrary", enabled: false, priority: 1, hasApiKey: false }, + { provider: "googlebooks", enabled: false, priority: 2, hasApiKey: false }, + { provider: "bnf", enabled: false, priority: 3, hasApiKey: false } + ] +}; + +const defaultAutomationSettings: AutomationSettingsDto = { + watchLibraries: false, + autoEnrichNewBooks: false, + scanSchedule: { frequency: "disabled", time: "03:00", dayOfWeek: 1 }, + enrichSchedule: { frequency: "disabled", time: "04:00", dayOfWeek: 1 } +}; + +export function AdminAutomationPage() { + const [tab, setTab] = useState("sources"); + const [metadataState, setMetadataState] = useState>({ + initial: null, + draft: null, + loading: true, + saving: false + }); + const [automationState, setAutomationState] = useState>({ + initial: null, + draft: null, + loading: true, + saving: false + }); + const [apiKeys, setApiKeys] = useState>>({}); + + const metadataDirty = useMemo( + () => Boolean(metadataState.initial && metadataState.draft && JSON.stringify(metadataState.initial) !== JSON.stringify(metadataState.draft)), + [metadataState.initial, metadataState.draft] + ); + const automationDirty = useMemo( + () => + Boolean(automationState.initial && automationState.draft && JSON.stringify(automationState.initial) !== JSON.stringify(automationState.draft)), + [automationState.initial, automationState.draft] + ); + + async function refreshMetadata() { + setMetadataState((current) => ({ ...current, loading: true, error: undefined, success: undefined })); + try { + const next = normalizeMetadataSources(await api.metadataSources()); + setMetadataState({ initial: next, draft: next, loading: false, saving: false }); + setApiKeys({}); + } catch (error) { + const fallback = getApiFallback(error); + const next = normalizeMetadataSources(fallback ?? defaultMetadataConfig); + setMetadataState({ + initial: next, + draft: next, + loading: false, + saving: false, + error: fallback ? "Sources chargees en mode degrade." : "Lecture des sources impossible." + }); + } + } + + async function refreshAutomation() { + setAutomationState((current) => ({ ...current, loading: true, error: undefined, success: undefined })); + try { + const next = await api.automationSettings(); + setAutomationState({ initial: next, draft: next, loading: false, saving: false }); + } catch (error) { + const fallback = getApiFallback(error); + const next = fallback ?? defaultAutomationSettings; + setAutomationState({ + initial: next, + draft: next, + loading: false, + saving: false, + error: fallback ? "Automatisation chargee en mode degrade." : "Lecture de l'automatisation impossible." + }); + } + } + + useEffect(() => { + void refreshMetadata(); + void refreshAutomation(); + }, []); + + async function saveMetadata(event: FormEvent) { + event.preventDefault(); + if (!metadataState.draft) return; + setMetadataState((current) => ({ ...current, saving: true, error: undefined, success: undefined })); + try { + const payload = metadataSourcesPayload(metadataState.draft); + payload.sources = payload.sources?.map((source) => { + const apiKey = apiKeys[source.provider]?.trim(); + return apiKey ? { ...source, apiKey } : source; + }); + const next = normalizeMetadataSources(await api.updateMetadataSources(payload)); + setMetadataState({ initial: next, draft: next, loading: false, saving: false, success: "Sources enregistrees." }); + setApiKeys({}); + } catch (error) { + setMetadataState((current) => ({ + ...current, + saving: false, + error: error instanceof Error ? error.message : "Enregistrement des sources impossible." + })); + } + } + + async function saveAutomation(event: FormEvent) { + event.preventDefault(); + if (!automationState.draft) return; + setAutomationState((current) => ({ ...current, saving: true, error: undefined, success: undefined })); + try { + const next = await api.updateAutomationSettings(automationState.draft); + setAutomationState({ initial: next, draft: next, loading: false, saving: false, success: "Automatisation enregistree." }); + } catch (error) { + setAutomationState((current) => ({ + ...current, + saving: false, + error: error instanceof Error ? error.message : "Enregistrement de l'automatisation impossible." + })); + } + } + + async function runNow(kind: "scan" | "enrich") { + setAutomationState((current) => ({ ...current, error: undefined, success: undefined })); + try { + if (kind === "scan") await api.runAutomationScan(); + else await api.runAutomationEnrich(); + setAutomationState((current) => ({ + ...current, + success: kind === "scan" ? "Scan planifie demande." : "Enrichissement planifie demande." + })); + } catch (error) { + setAutomationState((current) => ({ + ...current, + error: error instanceof Error ? error.message : "Demande impossible." + })); + } + } + + return ( +
+ +
+
+

Automatisation & enrichissement

+

Sources, priorites et taches recurrentes.

+
+ {metadataDirty || automationDirty ? "modifications non enregistrees" : "a jour"} +
+
+ + +
+
+ + {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: MetadataSourcesConfigDto) => 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) => ( +
+ + +
+ + +
+
+ ))} +
+
+ + + + ); +} + +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 2e42aef..d3573dc 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -13,6 +13,7 @@ export function AdminPage() { const [path, setPath] = useState("/library"); const [error, setError] = useState(); const [success, setSuccess] = useState(); + const [scanRetryLibrary, setScanRetryLibrary] = useState(); async function refresh() { setLoading(true); @@ -53,28 +54,38 @@ export function AdminPage() { 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(); - setSuccess(`Bibliothèque "${name}" ajoutée.`); + 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) { - const fallback = getApiFallback(createError); - if (fallback) setLibraries((current) => [fallback, ...current]); - setError(fallback ? "Creation en mode secours, synchronisation a retenter." : "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) { - const fallback = getApiFallback(scanError); - if (fallback) setJobs((current) => [fallback, ...current]); - setError(fallback ? "Scan place en file de secours, statut a verifier." : "Scan impossible"); + setError(scanError instanceof Error ? `Scan impossible : ${scanError.message}` : "Scan impossible."); } } @@ -86,6 +97,7 @@ export function AdminPage() { setError(undefined); setSuccess(undefined); + setScanRetryLibrary(undefined); try { await api.deleteLibrary(library.id); setLibraries((current) => current.filter((item) => item.id !== library.id)); @@ -104,14 +116,22 @@ export function AdminPage() { {success &&
{success}
} - {error && ( + {scanRetryLibrary ? ( +
+ La bibliothèque est conservée dans la liste. + +
+ ) : error ? (
Les formulaires restent disponibles.
- )} + ) : null}