merge: develop dans main — release v1.0.0 (lecteur PDF/EPUB/CBZ avec zoom/pinch/modes, accueil refondu, pages d'erreur, profil clarifié — tickets #34 à #48)

This commit is contained in:
Git Agent
2026-08-28 23:56:17 +02:00
126 changed files with 13104 additions and 376 deletions

10
.env.example Normal file
View File

@ -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

3
.gitignore vendored
View File

@ -5,7 +5,8 @@ dist
*.sqlite
*.sqlite-*
*.tsbuildinfo
data/storage
data/
Books/
coverage
.pnpm-store
.ideai/

View File

@ -2,6 +2,16 @@
ReadaBook est une application locale-first pour cataloguer, rechercher et lire une bibliothèque personnelle de livres EPUB/PDF stockés sur disque. Le MVP livré vise un usage domestique : un administrateur déclare un dossier local, lance un scan, puis les livres deviennent accessibles via un catalogue web et une API locale.
## Dépôt distant
Le dépôt est hébergé sur un Gitea self-hosted et peut être cloné via :
```bash
git clone https://gitea.anthonybouteiller.ovh/blomios/ReadaBook.git
```
Le déploiement git suit un git-flow simplifié : `main` (releases), `develop` (intégration), `feature/*` / `fix/*` (travail en cours).
## Fonctionnalités MVP présentes
- Backend NestJS/Fastify exécutable avec healthcheck `GET /healthz`.
@ -94,12 +104,14 @@ La PWA fournit `manifest.webmanifest`, `sw.js`, une icône maskable SVG et `disp
Volumes :
- `./data:/data` : base SQLite `/data/readabook.sqlite` et cache `/data/storage`.
- `./data/library:/library:ro` : bibliothèque locale scannée en lecture seule.
- `${READABOOK_LIBRARY_HOST_PATH:-./data/library}:/library:ro` : bibliothèque locale scannée en lecture seule.
Variables principales :
- `JWT_SECRET` : secret JWT, à changer hors développement.
- `OPEN_LIBRARY_ENABLED=true|false` : active/désactive lenrichissement distant.
- `READABOOK_LIBRARY_HOST_PATH` : dossier hôte monté en lecture seule sur `/library`.
- `READABOOK_LIBRARY_ALIAS_FROM` : chemin alternatif accepté par lAPI et traduit vers `/library`.
- `DATABASE_PATH=/data/readabook.sqlite`
- `STORAGE_DIR=/data/storage`
@ -134,6 +146,16 @@ curl -b cookies.txt \
http://localhost:3000/admin/libraries
```
Pour tester le corpus réel local `Books/` sans le versionner, crée un `.env` local :
```bash
READABOOK_LIBRARY_HOST_PATH=./Books
READABOOK_LIBRARY_ALIAS_FROM=/chemin/absolu/vers/ReadaBook/Books
```
QA peut ensuite créer la bibliothèque avec le chemin absolu saisi dans `READABOOK_LIBRARY_ALIAS_FROM`;
lAPI le traduit vers `/library`, puis le scan manuel teste lextraction ISBN/métadonnées/jaquettes sur ce corpus.
## Lancer un scan
```bash

View File

@ -26,6 +26,7 @@
"fastify": "^5.2.1",
"jose": "^5.9.6",
"mime-types": "^2.1.35",
"node-unrar-js": "^2.0.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^3.24.2"

View File

@ -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();
}
}

View File

@ -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]
})

View File

@ -1,14 +1,16 @@
import { Module } from "@nestjs/common";
import { AdminModule } from "./admin/admin.module.js";
import { AutomationModule } from "./automation/automation.module.js";
import { AuthModule } from "./auth/auth.module.js";
import { BooksModule } from "./books/books.module.js";
import { DatabaseModule } from "./database/database.module.js";
import { ProgressModule } from "./progress/progress.module.js";
import { ReaderModule } from "./reader/reader.module.js";
import { ScannerModule } from "./scanner/scanner.module.js";
import { HealthController } from "./health.controller.js";
@Module({
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ScannerModule],
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ScannerModule, AutomationModule],
controllers: [HealthController]
})
export class AppModule {}

View File

@ -1,7 +1,14 @@
import { Body, Controller, Get, Post, Res, UseGuards } from "@nestjs/common";
import { Body, Controller, Get, Patch, Post, Res, UseGuards } from "@nestjs/common";
import "@fastify/cookie";
import { FastifyReply } from "fastify";
import { BootstrapAdminDto, BootstrapAdminSchema, LoginDto, LoginSchema } from "@readabook/shared";
import {
BootstrapAdminDto,
BootstrapAdminSchema,
LoginDto,
LoginSchema,
UpdateAccountDto,
UpdateAccountSchema
} from "@readabook/shared";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { AuthGuard } from "./auth.guard.js";
import { AuthService } from "./auth.service.js";
@ -11,6 +18,11 @@ import { CurrentUser, CurrentUserParam } from "./current-user.js";
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Get("status")
async status() {
return this.auth.status();
}
@Post("bootstrap")
async bootstrap(@Body(new ZodValidationPipe(BootstrapAdminSchema)) body: BootstrapAdminDto) {
return this.auth.bootstrapAdmin(body);
@ -40,4 +52,13 @@ export class AuthController {
me(@CurrentUserParam() user: CurrentUser) {
return { user };
}
@Patch("me")
@UseGuards(AuthGuard)
updateMe(
@CurrentUserParam() user: CurrentUser,
@Body(new ZodValidationPipe(UpdateAccountSchema)) body: UpdateAccountDto
) {
return this.auth.updateOwnAccount(user.id, body);
}
}

View File

@ -1,14 +1,14 @@
import { ConflictException, Injectable, UnauthorizedException } from "@nestjs/common";
import { ConflictException, Injectable, OnModuleInit, UnauthorizedException } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { SignJWT, jwtVerify } from "jose";
import argon2 from "argon2";
import { BootstrapAdminDto, CreateUserDto, LoginDto, UpdateUserDto } from "@readabook/shared";
import { BootstrapAdminDto, CreateUserDto, LoginDto, UpdateAccountDto, UpdateUserDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { users } from "../database/schema.js";
import { CurrentUser } from "./current-user.js";
@Injectable()
export class AuthService {
export class AuthService implements OnModuleInit {
readonly cookieName: string;
private readonly secret: Uint8Array;
@ -17,6 +17,10 @@ export class AuthService {
this.secret = new TextEncoder().encode(database.config.jwtSecret);
}
async onModuleInit(): Promise<void> {
await this.ensureInitialAdmin();
}
get cookieSecure(): boolean {
return this.database.config.cookieSecure;
}
@ -29,6 +33,26 @@ export class AuthService {
return this.createUser({ ...input, role: "admin" });
}
async status() {
const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get();
const initialAdmin = this.database.db
.select({ passwordHash: users.passwordHash, role: users.role })
.from(users)
.where(eq(users.email, this.database.config.initialAdminEmail.toLowerCase()))
.get();
const initialAdminPasswordIsDefault =
Boolean(initialAdmin) &&
initialAdmin?.role === "admin" &&
this.database.config.initialAdminPasswordIsDefault &&
(await argon2.verify(initialAdmin.passwordHash, this.database.config.initialAdminPassword));
return {
hasUsers: Boolean(existing),
initialAdminEmail: this.database.config.initialAdminEmail.toLowerCase(),
initialAdminPasswordIsDefault
};
}
async login(input: LoginDto): Promise<{ token: string; user: CurrentUser }> {
const user = this.database.db.select().from(users).where(eq(users.email, input.email.toLowerCase())).get();
if (!user || !(await argon2.verify(user.passwordHash, input.password))) {
@ -65,6 +89,35 @@ export class AuthService {
.all();
}
async updateOwnAccount(id: number, input: UpdateAccountDto) {
const user = this.database.db.select().from(users).where(eq(users.id, id)).get();
if (!user || !(await argon2.verify(user.passwordHash, input.currentPassword))) {
throw new UnauthorizedException("Current password is invalid");
}
const values: Partial<typeof users.$inferInsert> = { updatedAt: this.database.now() };
if (input.email) values.email = input.email.toLowerCase();
if (input.name !== undefined) values.name = input.name;
if (input.newPassword) values.passwordHash = await argon2.hash(input.newPassword);
try {
return this.database.db
.update(users)
.set(values)
.where(eq(users.id, id))
.returning({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
createdAt: users.createdAt
})
.get();
} catch {
throw new ConflictException("Email already exists");
}
}
async createUser(input: CreateUserDto) {
const now = this.database.now();
const passwordHash = await argon2.hash(input.password);
@ -125,4 +178,22 @@ export class AuthService {
.setExpirationTime("7d")
.sign(this.secret);
}
private async ensureInitialAdmin(): Promise<void> {
const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get();
if (existing) return;
const now = this.database.now();
this.database.db
.insert(users)
.values({
email: this.database.config.initialAdminEmail.toLowerCase(),
name: "Initial administrator",
passwordHash: await argon2.hash(this.database.config.initialAdminPassword),
role: "admin",
createdAt: now,
updatedAt: now
})
.run();
}
}

View File

@ -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 {}

View File

@ -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");
});
});

View File

@ -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<number, WatchEntry>();
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<AutomationScheduleDto>);
} catch {
return defaultSchedule;
}
}
function normalizeSchedule(value: Partial<AutomationScheduleDto>): 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<string> {
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));
}
}
}

View File

@ -1,4 +1,4 @@
import { Controller, Get, Param, Query, Res, UseGuards } from "@nestjs/common";
import { Controller, Get, Headers, Param, Query, Res, UseGuards } from "@nestjs/common";
import { FastifyReply } from "fastify";
import { lookup } from "mime-types";
import { BookQueryDto, BookQuerySchema } from "@readabook/shared";
@ -26,11 +26,31 @@ export class BooksController {
return this.books.get(Number(id));
}
@Get(":id/pages")
pages(@Param("id") id: string) {
return this.books.listComicPages(Number(id));
}
@Get(":id/pages/:page")
async page(@Param("id") id: string, @Param("page") page: string, @Res() reply: FastifyReply) {
const result = await this.books.readComicPage(Number(id), Number(page));
reply.header("Content-Type", result.contentType);
reply.header("Cache-Control", "private, max-age=3600");
return reply.send(result.data);
}
@Get(":id/file")
file(@Param("id") id: string, @Res() reply: FastifyReply) {
const { book, stream } = this.books.streamFile(Number(id));
reply.header("Content-Type", lookup(book.filePath) || "application/octet-stream");
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(book.title)}.${book.format}"`);
file(@Param("id") id: string, @Headers("range") range: string | undefined, @Res() reply: FastifyReply) {
const { book, stream, contentLength, contentType, end, partial, size, start } = this.books.streamFile(Number(id), range);
if (partial) {
reply.code(206);
reply.header("Content-Range", `bytes ${start}-${end}/${size}`);
}
reply.header("Accept-Ranges", "bytes");
reply.header("Content-Length", String(contentLength));
reply.header("Content-Type", contentType || lookup(book.filePath) || "application/octet-stream");
reply.header("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(`${book.title}.${book.format}`)}`);
reply.header("X-Content-Type-Options", "nosniff");
return reply.send(stream);
}

View File

@ -3,10 +3,11 @@ import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js";
import { BooksController } from "./books.controller.js";
import { BooksService } from "./books.service.js";
import { SeriesController } from "./series.controller.js";
@Module({
imports: [AuthModule, DatabaseModule],
controllers: [BooksController],
controllers: [BooksController, SeriesController],
providers: [BooksService],
exports: [BooksService]
})

View File

@ -0,0 +1,264 @@
import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { extname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { BookQuerySchema } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { books, libraries, series } from "../database/schema.js";
import { BooksService } from "./books.service.js";
const previousDatabasePath = process.env.DATABASE_PATH;
const previousStorageDir = process.env.STORAGE_DIR;
const realBooksPath = "/home/anthony/Documents/Projects/ReadaBook/Books";
const tempDirs: string[] = [];
afterEach(() => {
process.env.DATABASE_PATH = previousDatabasePath;
process.env.STORAGE_DIR = previousStorageDir;
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("BooksService", () => {
it.runIf(canLoadBetterSqlite() && canReadRealBooksCorpus())("exposes every persisted real corpus book through the default catalogue query", () => {
const database = createDatabase();
try {
const service = new BooksService(database);
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Real corpus", path: realBooksPath, enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const files = realCorpusBookFiles();
for (const [index, filePath] of files.entries()) {
database.db
.insert(books)
.values({
libraryId: library.id,
seriesId: null,
title: `Corpus ${String(index + 1).padStart(3, "0")}`,
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: null,
localMetadataJson: null,
language: null,
publisher: null,
publishedDate: null,
volumeNumber: null,
volumeLabel: null,
format: bookFormatFromPath(filePath),
filePath,
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: JSON.stringify({ title: "local" }),
scanStatus: "succeeded",
enrichmentStatus: "idle",
fileSize: statSync(filePath).size,
fileMtime: statSync(filePath).mtime.toISOString(),
createdAt: now,
updatedAt: now
})
.run();
}
expect(files.length).toBeGreaterThan(50);
expect(service.count()).toBe(files.length);
expect(service.list(BookQuerySchema.parse({}))).toHaveLength(files.length);
} finally {
database.onModuleDestroy();
}
});
it.runIf(canLoadBetterSqlite())("exposes metadata status and parsed provenance on book API rows", () => {
const database = createDatabase();
const service = new BooksService(database);
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const daredevil = database.db
.insert(series)
.values({
title: "Daredevil",
normalizedTitle: "daredevil",
description: "Collection Daredevil",
publisher: "Marvel",
createdAt: now,
updatedAt: now
})
.returning()
.get();
const book = database.db
.insert(books)
.values({
libraryId: library.id,
seriesId: daredevil.id,
title: "Daredevil",
author: "Roy Thomas",
description: "Daredevil affronte une nouvelle menace.",
isbn: "9782809476255",
isbn13: "9782809476255",
identifiersJson: null,
localMetadataJson: null,
language: "fre",
publisher: "Panini comics",
publishedDate: "0101-01-01T00:00:00+00:00",
volumeNumber: 1,
volumeLabel: "001",
format: "cbz",
filePath: "/library/Daredevil.cbz",
coverPath: "/storage/covers/daredevil.jpg",
metadataStatus: "enriched",
metadataProvenanceJson: JSON.stringify({ title: "local", author: "bnf", coverPath: "openlibrary" }),
scanStatus: "succeeded",
enrichmentStatus: "succeeded",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.returning()
.get();
expect(service.get(book.id)).toMatchObject({
publishedDate: null,
seriesId: daredevil.id,
volumeNumber: 1,
volumeLabel: "001",
series: { id: daredevil.id, title: "Daredevil", normalizedTitle: "daredevil" },
metadataStatus: "enriched",
metadataProvenance: { title: "local", author: "bnf", coverPath: "openlibrary" }
});
expect(service.list({ limit: 50, offset: 0 })[0]).toMatchObject({
publishedDate: null,
seriesId: daredevil.id,
volumeNumber: 1,
volumeLabel: "001",
series: { id: daredevil.id, title: "Daredevil", normalizedTitle: "daredevil" },
metadataStatus: "enriched",
metadataProvenance: { title: "local", author: "bnf", coverPath: "openlibrary" }
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("lists a series with distinct books sharing the same volume", () => {
const database = createDatabase();
const service = new BooksService(database);
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const soloLeveling = database.db
.insert(series)
.values({
title: "Solo Leveling",
normalizedTitle: "solo leveling",
description: null,
publisher: null,
createdAt: now,
updatedAt: now
})
.returning()
.get();
for (const filePath of ["/library/Solo Leveling T03.cbz", "/library/Solo Leveling 003.cbz"]) {
database.db
.insert(books)
.values({
libraryId: library.id,
seriesId: soloLeveling.id,
title: filePath.includes("T03") ? "Solo Leveling T03" : "Solo Leveling 003",
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: null,
localMetadataJson: null,
language: null,
publisher: null,
publishedDate: null,
volumeNumber: 3,
volumeLabel: filePath.includes("T03") ? "T03" : "003",
format: "cbz",
filePath,
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: JSON.stringify({ title: "local" }),
scanStatus: "succeeded",
enrichmentStatus: "idle",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.run();
}
const result = service.getSeries(soloLeveling.id);
expect(result).toMatchObject({ id: soloLeveling.id, title: "Solo Leveling", normalizedTitle: "solo leveling" });
expect(result.books).toHaveLength(2);
expect(result.books.map((book) => [book.title, book.volumeNumber])).toEqual([
["Solo Leveling 003", 3],
["Solo Leveling T03", 3]
]);
database.onModuleDestroy();
});
});
function createDatabase(): DatabaseService {
const dir = mkdtempSync(join(tmpdir(), "readabook-books-service-"));
tempDirs.push(dir);
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
process.env.STORAGE_DIR = join(dir, "storage");
return new DatabaseService();
}
function canLoadBetterSqlite(): boolean {
try {
const database = createDatabase();
database.onModuleDestroy();
return true;
} catch {
return false;
}
}
function canReadRealBooksCorpus(): boolean {
try {
return realCorpusBookFiles().length > 0;
} catch {
return false;
}
}
function realCorpusBookFiles(root = realBooksPath): string[] {
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
const path = join(root, entry.name);
if (entry.isDirectory()) return realCorpusBookFiles(path);
if (!entry.isFile()) return [];
return isBookFile(path) ? [path] : [];
});
}
function isBookFile(filePath: string): boolean {
return [".epub", ".pdf", ".cbz", ".cbr"].includes(extname(filePath).toLowerCase());
}
function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" | "cbr" {
const extension = extname(filePath).toLowerCase();
if (extension === ".epub") return "epub";
if (extension === ".cbz") return "cbz";
if (extension === ".cbr") return "cbr";
return "pdf";
}

View File

@ -1,9 +1,13 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { createReadStream, existsSync } from "node:fs";
import { BadRequestException, HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common";
import { createReadStream, existsSync, statSync } from "node:fs";
import { extname } from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { BookQueryDto } from "@readabook/shared";
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
import { DatabaseService } from "../database/database.service.js";
import { books } from "../database/schema.js";
import { books, series } from "../database/schema.js";
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
@Injectable()
export class BooksService {
@ -16,17 +20,33 @@ export class BooksService {
if (query.q) {
return this.search(query.q, query.limit, query.offset);
}
return this.database.db
const statement = this.database.db
.select()
.from(books)
.where(filters.length ? and(...filters) : undefined)
.orderBy(books.title)
.limit(query.limit)
.offset(query.offset)
.all();
.orderBy(books.title);
const rows =
query.limit === undefined ? statement.all() : statement.limit(query.limit).offset(query.offset).all();
return rows
.map((book) => this.mapBookSelect(book));
}
search(q: string, limit = 50, offset = 0) {
search(q: string, limit?: number, offset = 0) {
if (limit === undefined) {
const rows = this.database.sqlite
.prepare(
`
SELECT books.*
FROM book_fts
JOIN books ON books.id = book_fts.rowid
WHERE book_fts MATCH ?
ORDER BY bm25(book_fts)
`
)
.all(`${q.replace(/"/g, '""')}*`);
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
}
const rows = this.database.sqlite
.prepare(
`
@ -39,10 +59,86 @@ export class BooksService {
`
)
.all(`${q.replace(/"/g, '""')}*`, limit, offset);
return (rows as Array<Record<string, unknown>>).map(mapBookRow);
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
}
get(id: number) {
return this.mapBookSelect(this.getRecord(id));
}
listSeries() {
return this.database.db.select().from(series).orderBy(series.title).all();
}
getSeries(id: number) {
const row = this.database.db.select().from(series).where(eq(series.id, id)).get();
if (!row) throw new NotFoundException("Series not found");
const seriesBooks = this.database.db
.select()
.from(books)
.where(eq(books.seriesId, id))
.orderBy(books.volumeNumber, books.title)
.all()
.map((book) => this.mapBookSelect(book));
return { ...row, books: seriesBooks };
}
streamFile(id: number, range?: string) {
const book = this.getRecord(id);
if (!existsSync(book.filePath)) {
throw new NotFoundException("Book file not found on disk");
}
const size = statSync(book.filePath).size;
const byteRange = parseByteRange(range, size);
const stream = createReadStream(book.filePath, { start: byteRange.start, end: byteRange.end });
return {
book,
stream,
contentType: bookContentType(book.format),
contentLength: byteRange.end - byteRange.start + 1,
size,
...byteRange
};
}
streamCover(id: number) {
const book = this.getRecord(id);
if (!book.coverPath || !existsSync(book.coverPath)) {
throw new NotFoundException("Cover not found");
}
return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath };
}
async listComicPages(id: number) {
const book = this.getRecord(id);
this.assertComicArchiveBook(book);
const pages = book.format === "cbr" ? await listCbrImageEntries(book.filePath) : listCbzImageEntries(book.filePath);
return {
bookId: book.id,
pageCount: pages.length,
pages: pages.map((page, index) => ({ page: index + 1, name: page.name }))
};
}
async readComicPage(id: number, page: number) {
const book = this.getRecord(id);
this.assertComicArchiveBook(book);
try {
const result =
book.format === "cbr"
? await readCbrPage(book.filePath, page, this.database.config.storageDir)
: readCbzPage(book.filePath, page);
return { book, page, contentType: lookupMime(result.entryName), data: result.data };
} catch (error) {
throw new NotFoundException(error instanceof Error ? error.message : "Comic page not found");
}
}
count() {
return this.database.db.select({ count: sql<number>`count(*)` }).from(books).get()?.count ?? 0;
}
private getRecord(id: number): typeof books.$inferSelect {
const book = this.database.db.select().from(books).where(eq(books.id, id)).get();
if (!book) {
throw new NotFoundException("Book not found");
@ -50,48 +146,121 @@ export class BooksService {
return book;
}
streamFile(id: number) {
const book = this.get(id);
private assertComicArchiveBook(book: typeof books.$inferSelect): void {
if (book.format !== "cbz" && book.format !== "cbr") {
throw new BadRequestException("Book is not a comic archive");
}
if (!existsSync(book.filePath)) {
throw new NotFoundException("Book file not found on disk");
}
return { book, stream: createReadStream(book.filePath) };
}
streamCover(id: number) {
const book = this.get(id);
if (!book.coverPath || !existsSync(book.coverPath)) {
throw new NotFoundException("Cover not found");
}
return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath };
private mapBookRow(row: Record<string, unknown>) {
const seriesId = nullable(row.series_id);
return {
id: Number(row.id),
libraryId: Number(row.library_id),
seriesId: seriesId ? Number(seriesId) : null,
title: String(row.title),
author: nullable(row.author),
description: nullable(row.description),
isbn: nullable(row.isbn),
isbn13: nullable(row.isbn13),
language: nullable(row.language),
publisher: nullable(row.publisher),
publishedDate: normalizePublishedDate(nullable(row.published_date)),
volumeNumber: row.volume_number === null || row.volume_number === undefined ? null : Number(row.volume_number),
volumeLabel: nullable(row.volume_label),
format: row.format,
filePath: String(row.file_path),
coverPath: nullable(row.cover_path),
metadataStatus: metadataStatusValue(row.metadata_status),
metadataProvenance: parseObject(row.metadata_provenance_json),
series: seriesId ? this.getSeriesRecord(Number(seriesId)) : null,
scanStatus: statusValue(row.scan_status),
enrichmentStatus: statusValue(row.enrichment_status),
fileSize: Number(row.file_size),
fileMtime: String(row.file_mtime),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at)
};
}
count() {
return this.database.db.select({ count: sql<number>`count(*)` }).from(books).get()?.count ?? 0;
private mapBookSelect(row: typeof books.$inferSelect) {
const { metadataProvenanceJson: _metadataProvenanceJson, ...book } = row;
return {
...book,
publishedDate: normalizePublishedDate(row.publishedDate),
metadataProvenance: parseObject(row.metadataProvenanceJson),
series: row.seriesId ? this.getSeriesRecord(row.seriesId) : null
};
}
private getSeriesRecord(id: number) {
return this.database.db.select().from(series).where(eq(series.id, id)).get() ?? null;
}
}
function mapBookRow(row: Record<string, unknown>) {
return {
id: Number(row.id),
libraryId: Number(row.library_id),
title: String(row.title),
author: nullable(row.author),
description: nullable(row.description),
isbn: nullable(row.isbn),
language: nullable(row.language),
publisher: nullable(row.publisher),
publishedDate: nullable(row.published_date),
format: row.format,
filePath: String(row.file_path),
coverPath: nullable(row.cover_path),
fileSize: Number(row.file_size),
fileMtime: String(row.file_mtime),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at)
};
function parseByteRange(range: string | undefined, size: number): { start: number; end: number; partial: boolean } {
if (!range) return { start: 0, end: Math.max(size - 1, 0), partial: false };
const match = range.match(/^bytes=(\d*)-(\d*)$/);
if (!match || size <= 0) {
throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
}
const [, rawStart, rawEnd] = match;
let start: number;
let end: number;
if (!rawStart && rawEnd) {
const suffixLength = Number(rawEnd);
start = Math.max(size - suffixLength, 0);
end = size - 1;
} else {
start = Number(rawStart);
end = rawEnd ? Number(rawEnd) : size - 1;
}
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= size) {
throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
}
return { start, end: Math.min(end, size - 1), partial: true };
}
function bookContentType(format: string): string {
if (format === "epub") return "application/epub+zip";
if (format === "pdf") return "application/pdf";
if (format === "cbz") return "application/vnd.comicbook+zip";
if (format === "cbr") return "application/vnd.comicbook-rar";
return "application/octet-stream";
}
function lookupMime(entryName: string): string {
const extension = extname(entryName).toLowerCase();
if (extension === ".png") return "image/png";
if (extension === ".webp") return "image/webp";
if (extension === ".gif") return "image/gif";
if (extension === ".avif") return "image/avif";
return "image/jpeg";
}
function nullable(value: unknown): string | null {
return value === null || value === undefined ? null : String(value);
}
function statusValue(value: unknown): "idle" | "running" | "succeeded" | "failed" {
return value === "running" || value === "succeeded" || value === "failed" ? value : "idle";
}
function metadataStatusValue(value: unknown): "enriched" | "partial" | "none" {
return value === "enriched" || value === "partial" ? value : "none";
}
function parseObject(value: unknown): Record<string, string> {
if (typeof value !== "string") return {};
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return Object.fromEntries(Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
} catch {
return {};
}
}

View File

@ -0,0 +1,19 @@
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard.js";
import { BooksService } from "./books.service.js";
@Controller("series")
@UseGuards(AuthGuard)
export class SeriesController {
constructor(private readonly books: BooksService) {}
@Get()
list() {
return this.books.listSeries();
}
@Get(":id")
get(@Param("id") id: string) {
return this.books.getSeries(Number(id));
}
}

View File

@ -0,0 +1,64 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { extname, join } from "node:path";
import { createExtractorFromFile } from "node-unrar-js";
import { COMIC_IMAGE_EXTENSIONS, MAX_COMIC_ARCHIVE_ENTRIES } from "./cbz.js";
import type { CbzPageEntry } from "./cbz.js";
export async function listCbrImageEntries(filePath: string): Promise<CbzPageEntry[]> {
const extractor = await createExtractorFromFile({ filepath: filePath });
const list = extractor.getFileList();
if (list.arcHeader.flags.volume) {
throw new Error("Multi-volume CBR archives are not supported");
}
if (list.arcHeader.flags.headerEncrypted) {
throw new Error("Encrypted CBR archives are not supported");
}
const headers = [...list.fileHeaders];
if (headers.length > MAX_COMIC_ARCHIVE_ENTRIES) {
throw new Error("CBR archive has too many entries");
}
const images = headers
.filter((header) => !header.flags.directory && !header.flags.encrypted)
.filter((header) => COMIC_IMAGE_EXTENSIONS.has(extname(header.name).toLowerCase()))
.map((header) => ({ entryName: header.name, name: header.name.split(/[\\/]/).pop() ?? header.name }))
.sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true, sensitivity: "base" }));
if (!images.length) {
throw new Error("CBR archive does not contain readable image pages");
}
return images;
}
export async function readCbrPage(
filePath: string,
pageNumber: number,
storageDir: string
): Promise<{ entryName: string; data: Buffer }> {
if (!Number.isInteger(pageNumber) || pageNumber < 1) {
throw new Error("CBR page number must be a positive integer");
}
const pages = await listCbrImageEntries(filePath);
const page = pages[pageNumber - 1];
if (!page) {
throw new Error("CBR page not found");
}
mkdirSync(storageDir, { recursive: true });
const tempDir = mkdtempSync(join(storageDir, "cbr-page-"));
const safeName = `page${extname(page.entryName).toLowerCase() || ".jpg"}`;
try {
const extractor = await createExtractorFromFile({
filepath: filePath,
targetPath: tempDir,
filenameTransform: () => safeName
});
const extracted = extractor.extract({ files: [page.entryName] });
[...extracted.files];
return { entryName: page.entryName, data: readFileSync(join(tempDir, safeName)) };
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}

View File

@ -0,0 +1,46 @@
import { extname } from "node:path";
import AdmZip from "adm-zip";
export type CbzPageEntry = {
entryName: string;
name: string;
};
export const COMIC_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]);
export const MAX_COMIC_ARCHIVE_ENTRIES = 20000;
export function listCbzImageEntries(filePath: string): CbzPageEntry[] {
const zip = new AdmZip(filePath);
const entries = zip.getEntries();
if (entries.length > MAX_COMIC_ARCHIVE_ENTRIES) {
throw new Error("CBZ archive has too many entries");
}
const images = entries
.filter((entry) => !entry.isDirectory && COMIC_IMAGE_EXTENSIONS.has(extname(entry.entryName).toLowerCase()))
.map((entry) => ({ entryName: entry.entryName, name: entry.name }))
.sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true, sensitivity: "base" }));
if (!images.length) {
throw new Error("CBZ archive does not contain readable image pages");
}
return images;
}
export function readCbzPage(filePath: string, pageNumber: number): { entryName: string; data: Buffer } {
if (!Number.isInteger(pageNumber) || pageNumber < 1) {
throw new Error("CBZ page number must be a positive integer");
}
const zip = new AdmZip(filePath);
const pages = listCbzImageEntries(filePath);
const page = pages[pageNumber - 1];
if (!page) {
throw new Error("CBZ page not found");
}
const entry = zip.getEntry(page.entryName);
if (!entry) {
throw new Error("CBZ page not found");
}
return { entryName: entry.entryName, data: entry.getData() };
}

View File

@ -11,8 +11,15 @@ export type AppConfig = {
cookieName: string;
cookieSecure: boolean;
openLibraryEnabled: boolean;
libraryPathAliases: Array<{ from: string; to: string }>;
initialAdminEmail: string;
initialAdminPassword: string;
initialAdminPasswordIsDefault: boolean;
};
const DEFAULT_INITIAL_ADMIN_EMAIL = "admin@readabook.local";
const DEFAULT_INITIAL_ADMIN_PASSWORD = "readabook-admin-change-me";
export function loadConfig(): AppConfig {
const databasePath = resolve(process.env.DATABASE_PATH ?? "./data/readabook.sqlite");
const storageDir = resolve(process.env.STORAGE_DIR ?? "./data/storage");
@ -29,6 +36,26 @@ export function loadConfig(): AppConfig {
jwtSecret: process.env.JWT_SECRET ?? "dev-change-me-readabook",
cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session",
cookieSecure: process.env.COOKIE_SECURE === "true",
openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false"
openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false",
libraryPathAliases: parseLibraryPathAliases(process.env.LIBRARY_PATH_ALIASES),
initialAdminEmail: process.env.INITIAL_ADMIN_EMAIL ?? DEFAULT_INITIAL_ADMIN_EMAIL,
initialAdminPassword: process.env.INITIAL_ADMIN_PASSWORD ?? DEFAULT_INITIAL_ADMIN_PASSWORD,
initialAdminPasswordIsDefault: !process.env.INITIAL_ADMIN_PASSWORD
};
}
function parseLibraryPathAliases(value: string | undefined): Array<{ from: string; to: string }> {
if (!value) return [];
return value
.split(";")
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const separator = entry.indexOf("=");
if (separator === -1) return null;
const from = entry.slice(0, separator).trim();
const to = entry.slice(separator + 1).trim();
return from && to ? { from, to } : null;
})
.filter((entry): entry is { from: string; to: string } => Boolean(entry));
}

View File

@ -0,0 +1,243 @@
import Database from "better-sqlite3";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DatabaseService } from "./database.service.js";
import { books, libraries, series } from "./schema.js";
const previousDatabasePath = process.env.DATABASE_PATH;
const previousStorageDir = process.env.STORAGE_DIR;
const tempDirs: string[] = [];
afterEach(() => {
process.env.DATABASE_PATH = previousDatabasePath;
process.env.STORAGE_DIR = previousStorageDir;
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("database migrations", () => {
it.runIf(canLoadBetterSqlite())("adds metadata columns to an existing comic-capable books table before creating dependent indexes", () => {
const dir = mkdtempSync(join(tmpdir(), "readabook-migration-"));
tempDirs.push(dir);
const databasePath = join(dir, "readabook.sqlite");
const storageDir = join(dir, "storage");
const legacy = new Database(databasePath);
const now = new Date().toISOString();
legacy.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
path TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
title TEXT NOT NULL,
author TEXT,
description TEXT,
isbn TEXT,
language TEXT,
publisher TEXT,
published_date TEXT,
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
file_size INTEGER NOT NULL,
file_mtime TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
locator TEXT NOT NULL,
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, book_id)
);
CREATE TABLE jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed')),
detail TEXT,
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO libraries (id, name, path, enabled, created_at, updated_at)
VALUES (1, 'Corpus', '/library', 1, '${now}', '${now}');
INSERT INTO books (
library_id, title, author, description, isbn, language, publisher, published_date,
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
)
VALUES
(1, 'Solo Leveling T03', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Solo Leveling T03.cbz', NULL, 42, '${now}', '${now}', '${now}'),
(1, 'Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz.cbz', NULL, 42, '${now}', '${now}', '${now}'),
(1, 'Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz', NULL, 42, '${now}', '${now}', '${now}');
`);
legacy.close();
process.env.DATABASE_PATH = databasePath;
process.env.STORAGE_DIR = storageDir;
const database = new DatabaseService();
const bookColumns = database.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>;
const bookIndexes = database.sqlite.prepare("PRAGMA index_list(books)").all() as Array<{ name: string }>;
const metadataSources = database.sqlite.prepare("SELECT provider FROM metadata_source_config ORDER BY priority").all() as Array<{ provider: string }>;
const seriesRows = database.sqlite
.prepare(
`
SELECT books.title, books.volume_number, books.volume_label, series.title AS series_title, series.normalized_title
FROM books
JOIN series ON series.id = books.series_id
ORDER BY books.title
`
)
.all() as Array<{
title: string;
volume_number: number | null;
volume_label: string | null;
series_title: string;
normalized_title: string;
}>;
const automationSettings = database.sqlite.prepare("SELECT id, isbn_priority_enabled FROM automation_settings").get() as
| { id: number; isbn_priority_enabled: number }
| undefined;
expect(bookColumns.map((column) => column.name)).toContain("isbn13");
expect(bookColumns.map((column) => column.name)).toContain("identifiers_json");
expect(bookColumns.map((column) => column.name)).toContain("scan_status");
expect(bookColumns.map((column) => column.name)).toContain("enrichment_status");
expect(bookColumns.map((column) => column.name)).toContain("series_id");
expect(bookColumns.map((column) => column.name)).toContain("volume_number");
expect(bookColumns.map((column) => column.name)).toContain("volume_label");
expect(bookIndexes.map((index) => index.name)).toContain("books_isbn13_idx");
expect(bookIndexes.map((index) => index.name)).toContain("books_series_idx");
expect(seriesRows).toEqual([
{
title: "Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+",
volume_number: 1,
volume_label: "T01",
series_title: "Dragon Ball SD",
normalized_title: "dragon ball sd"
},
{
title: "Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz",
volume_number: 1,
volume_label: "T01",
series_title: "Eyeshield 21",
normalized_title: "eyeshield 21"
},
{
title: "Solo Leveling T03",
volume_number: 3,
volume_label: "T03",
series_title: "Solo Leveling",
normalized_title: "solo leveling"
}
]);
expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"]);
expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 });
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("backfills missing Daredevil volume numbers when series already exists", () => {
const dir = mkdtempSync(join(tmpdir(), "readabook-series-backfill-"));
tempDirs.push(dir);
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
process.env.STORAGE_DIR = join(dir, "storage");
const first = new DatabaseService();
const now = first.now();
const library = first.db
.insert(libraries)
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const daredevil = first.db
.insert(series)
.values({
title: "Daredevil",
normalizedTitle: "daredevil",
description: null,
publisher: null,
createdAt: now,
updatedAt: now
})
.returning()
.get();
first.db
.insert(books)
.values({
libraryId: library.id,
seriesId: daredevil.id,
title: "Daredevil",
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: null,
localMetadataJson: null,
language: null,
publisher: null,
publishedDate: null,
volumeNumber: null,
volumeLabel: null,
format: "cbz",
filePath: "/library/Daredevil - 001[Sebmov].cbz",
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: JSON.stringify({ title: "local" }),
scanStatus: "succeeded",
enrichmentStatus: "idle",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.run();
first.onModuleDestroy();
const second = new DatabaseService();
const row = second.sqlite.prepare("SELECT volume_number, volume_label FROM books WHERE file_path = ?").get(
"/library/Daredevil - 001[Sebmov].cbz"
) as { volume_number: number | null; volume_label: string | null };
expect(row).toEqual({ volume_number: 1, volume_label: "001" });
second.onModuleDestroy();
});
});
function canLoadBetterSqlite(): boolean {
try {
new Database(":memory:").close();
return true;
} catch {
return false;
}
}

View File

@ -2,6 +2,7 @@ import { Injectable, OnModuleDestroy } from "@nestjs/common";
import Database from "better-sqlite3";
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
import { AppConfig, loadConfig } from "../config/env.js";
import { extractSeriesVolume } from "../metadata/use-cases/extract-series-volume.js";
import * as schema from "./schema.js";
@Injectable()
@ -49,19 +50,39 @@ export class DatabaseService implements OnModuleDestroy {
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS series (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
normalized_title TEXT NOT NULL UNIQUE,
description TEXT,
publisher TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
series_id INTEGER REFERENCES series(id) ON DELETE SET NULL,
title TEXT NOT NULL,
author TEXT,
description TEXT,
isbn TEXT,
isbn13 TEXT,
identifiers_json TEXT,
local_metadata_json TEXT,
language TEXT,
publisher TEXT,
published_date TEXT,
format TEXT NOT NULL CHECK (format IN ('epub','pdf')),
volume_number INTEGER,
volume_label TEXT,
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
metadata_status TEXT NOT NULL DEFAULT 'none' CHECK (metadata_status IN ('enriched','partial','none')),
metadata_provenance_json TEXT,
scan_status TEXT NOT NULL DEFAULT 'idle' CHECK (scan_status IN ('idle','running','succeeded','failed')),
enrichment_status TEXT NOT NULL DEFAULT 'idle' CHECK (enrichment_status IN ('idle','running','succeeded','failed')),
file_size INTEGER NOT NULL,
file_mtime TEXT NOT NULL,
created_at TEXT NOT NULL,
@ -89,6 +110,26 @@ export class DatabaseService implements OnModuleDestroy {
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS metadata_source_config (
provider TEXT PRIMARY KEY CHECK (provider IN ('local','openlibrary','googlebooks','bnf','mangadex','comicvine')),
enabled INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL,
api_key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS automation_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
watch_libraries INTEGER NOT NULL DEFAULT 0,
auto_enrich_new_books INTEGER NOT NULL DEFAULT 1,
isbn_priority_enabled INTEGER NOT NULL DEFAULT 1,
scan_schedule_json TEXT NOT NULL,
enrich_schedule_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS book_fts USING fts5(
title,
author,
@ -119,6 +160,333 @@ export class DatabaseService implements OnModuleDestroy {
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
`);
this.ensureBooksSupportsComicArchives();
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
this.ensureBooksMetadataColumns();
this.ensureSeriesModel();
this.ensureReaderPreferencesTable();
this.ensureMetadataSourceConfigSupportsComicProviders();
this.ensureMetadataSourceConfigColumns();
this.ensureAutomationSettingsColumns();
this.ensureMetadataDefaults();
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
}
private ensureBooksSupportsComicArchives(): void {
const table = this.sqlite
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'books'")
.get() as { sql?: string } | undefined;
if (!table?.sql || (table.sql.includes("'cbz'") && table.sql.includes("'cbr'"))) return;
this.sqlite.exec(`
PRAGMA foreign_keys = OFF;
PRAGMA legacy_alter_table = ON;
DROP TRIGGER IF EXISTS books_ai;
DROP TRIGGER IF EXISTS books_ad;
DROP TRIGGER IF EXISTS books_au;
BEGIN;
ALTER TABLE books RENAME TO books_legacy_format;
CREATE TABLE books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
series_id INTEGER REFERENCES series(id) ON DELETE SET NULL,
title TEXT NOT NULL,
author TEXT,
description TEXT,
isbn TEXT,
isbn13 TEXT,
identifiers_json TEXT,
local_metadata_json TEXT,
language TEXT,
publisher TEXT,
published_date TEXT,
volume_number INTEGER,
volume_label TEXT,
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
metadata_status TEXT NOT NULL DEFAULT 'none' CHECK (metadata_status IN ('enriched','partial','none')),
metadata_provenance_json TEXT,
scan_status TEXT NOT NULL DEFAULT 'idle' CHECK (scan_status IN ('idle','running','succeeded','failed')),
enrichment_status TEXT NOT NULL DEFAULT 'idle' CHECK (enrichment_status IN ('idle','running','succeeded','failed')),
file_size INTEGER NOT NULL,
file_mtime TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO books (
id, library_id, title, author, description, isbn, isbn13, identifiers_json, local_metadata_json, language, publisher, published_date,
volume_number, volume_label, format, file_path, cover_path, metadata_status, metadata_provenance_json, scan_status, enrichment_status, file_size, file_mtime, created_at, updated_at
)
SELECT
id, library_id, title, author, description, isbn, NULL, NULL, NULL, language, publisher, published_date,
NULL, NULL,
format, file_path, cover_path, CASE WHEN cover_path IS NOT NULL OR author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL THEN 'partial' ELSE 'none' END, NULL,
'idle', 'idle', file_size, file_mtime, created_at, updated_at
FROM books_legacy_format;
DROP TABLE books_legacy_format;
COMMIT;
PRAGMA legacy_alter_table = OFF;
PRAGMA foreign_keys = ON;
CREATE UNIQUE INDEX IF NOT EXISTS books_file_path_unique ON books(file_path);
CREATE INDEX IF NOT EXISTS books_library_idx ON books(library_id);
CREATE INDEX IF NOT EXISTS books_title_idx ON books(title);
CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13);
CREATE TRIGGER IF NOT EXISTS books_ai AFTER INSERT ON books BEGIN
INSERT INTO book_fts(rowid, title, author, description, isbn)
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
CREATE TRIGGER IF NOT EXISTS books_ad AFTER DELETE ON books BEGIN
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
END;
CREATE TRIGGER IF NOT EXISTS books_au AFTER UPDATE ON books BEGIN
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
INSERT INTO book_fts(rowid, title, author, description, isbn)
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
`);
}
private ensureBooksMetadataColumns(): void {
const columns = this.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>;
const names = new Set(columns.map((column) => column.name));
if (!names.has("isbn13")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN isbn13 TEXT");
}
if (!names.has("identifiers_json")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN identifiers_json TEXT");
}
if (!names.has("local_metadata_json")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN local_metadata_json TEXT");
}
if (!names.has("metadata_status")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN metadata_status TEXT NOT NULL DEFAULT 'none'");
this.sqlite.exec(`
UPDATE books
SET metadata_status = CASE
WHEN cover_path IS NOT NULL AND (author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL) THEN 'enriched'
WHEN cover_path IS NOT NULL OR author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL THEN 'partial'
ELSE 'none'
END
`);
}
if (!names.has("metadata_provenance_json")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN metadata_provenance_json TEXT");
}
if (!names.has("scan_status")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN scan_status TEXT NOT NULL DEFAULT 'idle'");
}
if (!names.has("enrichment_status")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN enrichment_status TEXT NOT NULL DEFAULT 'idle'");
}
if (!names.has("series_id")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN series_id INTEGER REFERENCES series(id) ON DELETE SET NULL");
}
if (!names.has("volume_number")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN volume_number INTEGER");
}
if (!names.has("volume_label")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN volume_label TEXT");
}
this.sqlite.exec(`
UPDATE books
SET published_date = NULL
WHERE published_date IS NOT NULL
AND (
trim(published_date) = '0000'
OR substr(trim(published_date), 1, 10) IN ('0001-01-01', '0101-01-01', '1970-01-01')
OR CAST(substr(trim(published_date), 1, 4) AS INTEGER) < 1500
OR CAST(substr(trim(published_date), 1, 4) AS INTEGER) > 2027
)
`);
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13)");
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_local_metadata_idx ON books(local_metadata_json)");
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_series_idx ON books(series_id)");
}
private ensureSeriesModel(): void {
this.sqlite.exec(`
CREATE TABLE IF NOT EXISTS series (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
normalized_title TEXT NOT NULL UNIQUE,
description TEXT,
publisher TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS series_normalized_title_unique ON series(normalized_title);
CREATE INDEX IF NOT EXISTS books_series_idx ON books(series_id);
`);
this.backfillSeries();
}
private backfillSeries(): void {
const rows = this.sqlite.prepare("SELECT id, title, file_path, series_id FROM books WHERE series_id IS NULL OR volume_number IS NULL").all() as Array<{
id: number;
title: string;
file_path: string;
series_id: number | null;
}>;
if (!rows.length) return;
const now = this.now();
const insertSeries = this.sqlite.prepare(`
INSERT INTO series (title, normalized_title, description, publisher, created_at, updated_at)
VALUES (?, ?, NULL, NULL, ?, ?)
ON CONFLICT(normalized_title) DO UPDATE SET title = excluded.title, updated_at = excluded.updated_at
RETURNING id
`);
const updateBook = this.sqlite.prepare("UPDATE books SET series_id = ?, volume_number = ?, volume_label = ? WHERE id = ?");
const transaction = this.sqlite.transaction(() => {
for (const row of rows) {
const parsed = extractSeriesVolume(row.title, row.file_path);
if (row.series_id !== null && parsed.volumeNumber === null) continue;
const seriesId =
row.series_id ??
(insertSeries.get(parsed.seriesTitle, parsed.normalizedSeriesTitle, now, now) as { id: number }).id;
updateBook.run(seriesId, parsed.volumeNumber, parsed.volumeLabel, row.id);
}
});
transaction();
}
private ensureReaderPreferencesTable(): void {
this.sqlite.exec(`
CREATE TABLE IF NOT EXISTS reader_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
mode TEXT NOT NULL CHECK (mode IN ('paged','scrolled','horizontal','vertical')),
fit TEXT CHECK (fit IN ('page','width','height','auto')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, book_id)
);
CREATE INDEX IF NOT EXISTS reader_preferences_user_idx ON reader_preferences(user_id);
`);
}
private ensureMetadataSourceConfigColumns(): void {
const names = this.columnNames("metadata_source_config");
const now = sqlString(this.now());
if (!names.has("enabled")) {
this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1");
}
if (!names.has("priority")) {
this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN priority INTEGER NOT NULL DEFAULT 0");
}
if (!names.has("api_key")) {
this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN api_key TEXT");
}
if (!names.has("created_at")) {
this.sqlite.exec(`ALTER TABLE metadata_source_config ADD COLUMN created_at TEXT NOT NULL DEFAULT ${now}`);
}
if (!names.has("updated_at")) {
this.sqlite.exec(`ALTER TABLE metadata_source_config ADD COLUMN updated_at TEXT NOT NULL DEFAULT ${now}`);
}
}
private ensureMetadataSourceConfigSupportsComicProviders(): void {
const table = this.sqlite
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'metadata_source_config'")
.get() as { sql?: string } | undefined;
if (!table?.sql || (table.sql.includes("'mangadex'") && table.sql.includes("'comicvine'"))) return;
this.sqlite.exec(`
BEGIN;
ALTER TABLE metadata_source_config RENAME TO metadata_source_config_legacy_provider;
CREATE TABLE metadata_source_config (
provider TEXT PRIMARY KEY CHECK (provider IN ('local','openlibrary','googlebooks','bnf','mangadex','comicvine')),
enabled INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL,
api_key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO metadata_source_config (provider, enabled, priority, api_key, created_at, updated_at)
SELECT provider, enabled, priority, api_key, created_at, updated_at
FROM metadata_source_config_legacy_provider;
DROP TABLE metadata_source_config_legacy_provider;
COMMIT;
`);
}
private ensureAutomationSettingsColumns(): void {
const names = this.columnNames("automation_settings");
const now = sqlString(this.now());
const disabledScan = sqlString(JSON.stringify({ frequency: "disabled", time: "03:00", dayOfWeek: 1 }));
const disabledEnrich = sqlString(JSON.stringify({ frequency: "disabled", time: "04:00", dayOfWeek: 1 }));
if (!names.has("watch_libraries")) {
this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN watch_libraries INTEGER NOT NULL DEFAULT 0");
}
if (!names.has("auto_enrich_new_books")) {
this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN auto_enrich_new_books INTEGER NOT NULL DEFAULT 1");
}
if (!names.has("isbn_priority_enabled")) {
this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN isbn_priority_enabled INTEGER NOT NULL DEFAULT 1");
}
if (!names.has("scan_schedule_json")) {
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN scan_schedule_json TEXT NOT NULL DEFAULT ${disabledScan}`);
}
if (!names.has("enrich_schedule_json")) {
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN enrich_schedule_json TEXT NOT NULL DEFAULT ${disabledEnrich}`);
}
if (!names.has("created_at")) {
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN created_at TEXT NOT NULL DEFAULT ${now}`);
}
if (!names.has("updated_at")) {
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN updated_at TEXT NOT NULL DEFAULT ${now}`);
}
}
private columnNames(table: string): Set<string> {
const columns = this.sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
return new Set(columns.map((column) => column.name));
}
private ensureMetadataDefaults(): void {
const now = this.now();
const insertSource = this.sqlite.prepare(`
INSERT INTO metadata_source_config (provider, enabled, priority, api_key, created_at, updated_at)
VALUES (?, ?, ?, NULL, ?, ?)
ON CONFLICT(provider) DO NOTHING
`);
insertSource.run("local", 1, 0, now, now);
insertSource.run("openlibrary", this.config.openLibraryEnabled ? 1 : 0, 1, now, now);
insertSource.run("googlebooks", 0, 2, now, now);
insertSource.run("bnf", 0, 3, now, now);
insertSource.run("mangadex", 1, 4, now, now);
insertSource.run("comicvine", 0, 5, now, now);
this.sqlite
.prepare(
`
INSERT INTO automation_settings (
id, watch_libraries, auto_enrich_new_books, isbn_priority_enabled,
scan_schedule_json, enrich_schedule_json, created_at, updated_at
)
VALUES (1, 0, 1, 1, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
`
)
.run(
JSON.stringify({ frequency: "disabled", time: "03:00", dayOfWeek: 1 }),
JSON.stringify({ frequency: "disabled", time: "04:00", dayOfWeek: 1 }),
now,
now
);
}
}
function sqlString(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}

View File

@ -23,6 +23,20 @@ export const libraries = sqliteTable("libraries", {
updatedAt: text("updated_at").notNull()
});
export const series = sqliteTable(
"series",
{
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
normalizedTitle: text("normalized_title").notNull(),
description: text("description"),
publisher: text("publisher"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({ normalizedTitleIdx: uniqueIndex("series_normalized_title_unique").on(table.normalizedTitle) })
);
export const books = sqliteTable(
"books",
{
@ -30,16 +44,26 @@ export const books = sqliteTable(
libraryId: integer("library_id")
.notNull()
.references(() => libraries.id, { onDelete: "cascade" }),
seriesId: integer("series_id").references(() => series.id, { onDelete: "set null" }),
title: text("title").notNull(),
author: text("author"),
description: text("description"),
isbn: text("isbn"),
isbn13: text("isbn13"),
identifiersJson: text("identifiers_json"),
localMetadataJson: text("local_metadata_json"),
language: text("language"),
publisher: text("publisher"),
publishedDate: text("published_date"),
format: text("format", { enum: ["epub", "pdf"] }).notNull(),
volumeNumber: integer("volume_number"),
volumeLabel: text("volume_label"),
format: text("format", { enum: ["epub", "pdf", "cbz", "cbr"] }).notNull(),
filePath: text("file_path").notNull(),
coverPath: text("cover_path"),
metadataStatus: text("metadata_status", { enum: ["enriched", "partial", "none"] }).notNull().default("none"),
metadataProvenanceJson: text("metadata_provenance_json"),
scanStatus: text("scan_status", { enum: ["idle", "running", "succeeded", "failed"] }).notNull().default("idle"),
enrichmentStatus: text("enrichment_status", { enum: ["idle", "running", "succeeded", "failed"] }).notNull().default("idle"),
fileSize: integer("file_size").notNull(),
fileMtime: text("file_mtime").notNull(),
createdAt: text("created_at").notNull(),
@ -48,6 +72,24 @@ export const books = sqliteTable(
(table) => ({ filePathIdx: uniqueIndex("books_file_path_unique").on(table.filePath) })
);
export const readerPreferences = sqliteTable(
"reader_preferences",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
bookId: integer("book_id")
.notNull()
.references(() => books.id, { onDelete: "cascade" }),
mode: text("mode", { enum: ["paged", "scrolled", "horizontal", "vertical"] }).notNull(),
fit: text("fit", { enum: ["page", "width", "height", "auto"] }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({ userBookIdx: uniqueIndex("reader_preferences_user_book_unique").on(table.userId, table.bookId) })
);
export const progress = sqliteTable(
"progress",
{
@ -75,3 +117,23 @@ export const jobs = sqliteTable("jobs", {
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});
export const metadataSourceConfig = sqliteTable("metadata_source_config", {
provider: text("provider", { enum: ["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"] }).primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
apiKey: text("api_key"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});
export const automationSettings = sqliteTable("automation_settings", {
id: integer("id").primaryKey(),
watchLibraries: integer("watch_libraries", { mode: "boolean" }).notNull().default(false),
autoEnrichNewBooks: integer("auto_enrich_new_books", { mode: "boolean" }).notNull().default(true),
isbnPriorityEnabled: integer("isbn_priority_enabled", { mode: "boolean" }).notNull().default(true),
scanScheduleJson: text("scan_schedule_json").notNull(),
enrichScheduleJson: text("enrich_schedule_json").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});

View File

@ -1,12 +1,16 @@
import { Injectable } from "@nestjs/common";
import { desc, eq } from "drizzle-orm";
import { Injectable, OnModuleInit } from "@nestjs/common";
import { desc, eq, inArray } from "drizzle-orm";
import { DatabaseService } from "../database/database.service.js";
import { jobs } from "../database/schema.js";
@Injectable()
export class JobsService {
export class JobsService implements OnModuleInit {
constructor(private readonly database: DatabaseService) {}
onModuleInit(): void {
this.failInterruptedJobs();
}
create(type: string, detail?: string) {
const now = this.database.now();
return this.database.db
@ -43,4 +47,16 @@ export class JobsService {
list(limit = 50) {
return this.database.db.select().from(jobs).orderBy(desc(jobs.createdAt)).limit(limit).all();
}
private failInterruptedJobs(): void {
this.database.db
.update(jobs)
.set({
status: "failed",
error: "Job interrupted before completion, most likely by API shutdown or restart",
updatedAt: this.database.now()
})
.where(inArray(jobs.status, ["queued", "running"]))
.run();
}
}

View File

@ -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<typeof libraries.$inferInsert> = { 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
});
}
}
}

View File

@ -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));
});
});

View File

@ -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";
}

View File

@ -0,0 +1,96 @@
import { Injectable } from "@nestjs/common";
import { XMLParser } from "fast-xml-parser";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
import { providerFetch, providerHttpError } from "./provider-fetch.js";
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", removeNSPrefix: true });
@Injectable()
export class BnfProvider implements MetadataProvider {
readonly id = "bnf" as const;
async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise<MetadataMatch | null> {
const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10;
if (!isbn) {
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, _config);
return matches[0] ?? null;
}
const query = `bib.isbn all "${isbn}"`;
const matches = await this.searchSru(query, 1, lookup.identifiers.isbn13);
return matches[0] ?? null;
}
async searchByMetadata(query: MetadataSearchQuery, _config: MetadataProviderConfig): Promise<MetadataMatch[]> {
const title = query.title.replace(/"/g, " ");
const author = query.author?.replace(/"/g, " ");
const sruQuery = [`bib.title all "${title}"`, author ? `bib.author all "${author}"` : null, query.year ? `bib.date all "${query.year}"` : null]
.filter(Boolean)
.join(" and ");
return this.searchSru(sruQuery, 5, query.isbn ? toIsbn13(query.isbn) : null);
}
private async searchSru(query: string, maximumRecords: number, expectedIsbn13: string | null): Promise<MetadataMatch[]> {
const url = new URL("https://catalogue.bnf.fr/api/SRU");
url.searchParams.set("version", "1.2");
url.searchParams.set("operation", "searchRetrieve");
url.searchParams.set("query", query);
url.searchParams.set("maximumRecords", String(maximumRecords));
const response = await providerFetch(this.id, url, { timeoutMs: 5000 });
if (!response.ok) throw await providerHttpError(this.id, response, `BnF HTTP ${response.status}`);
const parsed = parser.parse(await response.text());
const records = asArray(parsed?.searchRetrieveResponse?.records?.record)
.map((entry) => (entry.recordData as Record<string, unknown> | undefined)?.record)
.filter((record): record is Record<string, unknown> => Boolean(record));
if (!records.length) return [];
return records
.map((record) => {
const fields = asArray(record.datafield);
return {
title: subfield(fields, "200", "a") ?? undefined,
author: subfield(fields, "200", "f") ?? ([subfield(fields, "700", "b"), subfield(fields, "700", "a")].filter(Boolean).join(" ") || null),
description: subfield(fields, "330", "a"),
isbn: bestIsbn(fields, expectedIsbn13),
language: subfield(fields, "101", "a"),
publisher: subfield(fields, "210", "c") ?? subfield(fields, "214", "c"),
publishedDate: normalizePublishedDate(cleanDate(subfield(fields, "210", "d") ?? subfield(fields, "214", "d")))
};
})
.sort((left, right) => Number(Boolean(right.isbn)) - Number(Boolean(left.isbn)));
}
}
function asArray(value: unknown): Array<Record<string, unknown>> {
if (!value) return [];
return Array.isArray(value) ? (value as Array<Record<string, unknown>>) : [value as Record<string, unknown>];
}
function field(fields: Array<Record<string, unknown>>, tag: string): Record<string, unknown> | undefined {
return fields.find((item) => item["@_tag"] === tag);
}
function subfield(fields: Array<Record<string, unknown>>, 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<Record<string, unknown>>, expectedIsbn13: string | null): string | null {
const values = fields
.filter((item) => item["@_tag"] === "073" || item["@_tag"] === "010")
.flatMap((item) => asArray(item.subfield))
.filter((item) => item["@_code"] === "a")
.map((item) => String(item["#text"] ?? "").replace(/[^0-9X]/gi, ""))
.filter(Boolean);
return (
values.find((candidate) => Boolean(expectedIsbn13) && toIsbn13(candidate) === expectedIsbn13) ??
values.find((candidate) => Boolean(toIsbn13(candidate))) ??
null
);
}
function cleanDate(value: string | null): string | null {
return value?.match(/\d{4}/)?.[0] ?? value;
}

View File

@ -0,0 +1,160 @@
import { Injectable } from "@nestjs/common";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
import { providerFetch } from "./provider-fetch.js";
@Injectable()
export class ComicVineProvider implements MetadataProvider {
readonly id = "comicvine" as const;
async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null> {
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, config);
return matches[0] ?? null;
}
async searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise<MetadataMatch[]> {
assertApiKey(config);
const title = cleanComicTitle(query.title);
const relaxed = title.replace(/\b\d{1,3}\b/g, " ").replace(/\s+/g, " ").trim();
const matches = [
...(await searchComicVine("volume", title, config)),
...(await searchComicVine("issue", title, config)),
...(relaxed && relaxed !== title ? await searchComicVine("volume", relaxed, config) : [])
];
return rankMatches(query.title, dedupe(matches));
}
}
export class ComicVineProviderError extends Error {
constructor(
readonly code: "missing-key" | "invalid-key" | "rate-limit" | "http",
readonly status: number,
message: string
) {
super(message);
this.name = "ComicVineProviderError";
}
}
async function searchComicVine(resource: "volume" | "issue", title: string, config: MetadataProviderConfig): Promise<MetadataMatch[]> {
const url = new URL("https://comicvine.gamespot.com/api/search/");
url.searchParams.set("api_key", config.apiKey!);
url.searchParams.set("format", "json");
url.searchParams.set("resources", resource);
url.searchParams.set("query", title);
url.searchParams.set("limit", "10");
url.searchParams.set(
"field_list",
resource === "volume" ? "id,name,description,image,start_year,publisher" : "id,name,description,image,cover_date,store_date,volume"
);
const response = await providerFetch("comicvine", url, {
headers: { "User-Agent": "ReadaBook/0.1 self-hosted metadata provider (Comic Vine; non-commercial)" },
timeoutMs: 6000
});
const data = (await parseComicVineResponse(response)) as { results?: Array<Record<string, unknown>> };
return (data.results ?? []).map((entry) => comicVineToMatch(resource, entry));
}
function assertApiKey(config: MetadataProviderConfig): void {
if (!config.apiKey?.trim()) {
throw new ComicVineProviderError("missing-key", 0, "Comic Vine API key is required");
}
}
async function parseComicVineResponse(response: Response): Promise<unknown> {
const data = (await response.json().catch(() => ({}))) as { status_code?: number; error?: string };
if (response.status === 429) throw new ComicVineProviderError("rate-limit", response.status, data.error ?? "Comic Vine rate limit");
if (response.status === 401 || response.status === 403) throw new ComicVineProviderError("invalid-key", response.status, data.error ?? "Comic Vine API key rejected");
if (!response.ok) throw new ComicVineProviderError("http", response.status, data.error ?? `Comic Vine HTTP ${response.status}`);
if (data.status_code && data.status_code !== 1) {
const code = data.status_code === 100 || data.status_code === 101 ? "invalid-key" : "http";
throw new ComicVineProviderError(code, 200, data.error ?? `Comic Vine status ${data.status_code}`);
}
return data;
}
function comicVineToMatch(resource: "volume" | "issue", entry: Record<string, unknown>): MetadataMatch & { comicVineRank?: number } {
const volume = objectValue(entry.volume);
const title = resource === "issue" ? [stringValue(volume.name), stringValue(entry.name)].filter(Boolean).join(" ") : stringValue(entry.name);
return {
title: title || undefined,
description: cleanHtml(stringValue(entry.description)),
publisher: stringValue(objectValue(entry.publisher).name),
publishedDate: normalizePublishedDate(resource === "volume" ? stringValue(entry.start_year) : yearFromDate(stringValue(entry.cover_date) ?? stringValue(entry.store_date))),
coverUrl: imageUrl(entry.image),
sourceId: stringValue(entry.id)
};
}
function cleanComicTitle(value: string): string {
return value
.replace(/\.[A-Za-z0-9]{2,5}$/g, " ")
.replace(/[._]+/g, " ")
.replace(/\b(FRENCH|TRUEFRENCH|MULTI|CBZ|CBR|EPUB|PDF|eBook|ebook|scan|digital)\b/gi, " ")
.replace(/\([^)]*\)/g, " ")
.replace(/\b(e?bdz|Paprika\+?|emuleCenter(?:\.|\s+)net)\b/gi, " ")
.replace(/\bT(?:ome)?\s*0?(\d{1,3})\b/gi, " $1 ")
.replace(/[+]+/g, " ")
.replace(/\s+-\s+/g, " ")
.replace(/\s*-\s*$/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function rankMatches(originalTitle: string, matches: Array<MetadataMatch & { comicVineRank?: number }>): MetadataMatch[] {
return [...matches]
.map((match) => ({ ...match, comicVineRank: comicRank(originalTitle, match) }))
.sort((left, right) => (right.comicVineRank ?? 0) - (left.comicVineRank ?? 0))
.map(({ comicVineRank: _rank, ...match }) => match);
}
function comicRank(originalTitle: string, match: MetadataMatch): number {
let rank = tokenOverlap(cleanComicTitle(originalTitle), match.title ?? "") * 10;
if (match.coverUrl) rank += 1;
if (match.description) rank += 1;
return rank;
}
function dedupe(matches: MetadataMatch[]): MetadataMatch[] {
const seen = new Set<string>();
return matches.filter((match) => {
const key = [match.sourceId, match.title].filter(Boolean).join("|");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function cleanHtml(value: string | null): string | null {
if (!value) return null;
return value.replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/\s+/g, " ").trim() || null;
}
function imageUrl(value: unknown): string | null {
const image = objectValue(value);
return stringValue(image.original_url) ?? stringValue(image.super_url) ?? stringValue(image.medium_url) ?? stringValue(image.small_url);
}
function yearFromDate(value: string | null): string | null {
return value?.match(/\b(1[5-9]\d{2}|20\d{2})\b/)?.[1] ?? null;
}
function tokenOverlap(left: string, right: string): number {
const leftTokens = new Set(normalizeTokens(left));
const rightTokens = new Set(normalizeTokens(right));
if (!leftTokens.size || !rightTokens.size) return 0;
return [...leftTokens].filter((token) => rightTokens.has(token)).length / leftTokens.size;
}
function normalizeTokens(value: string): string[] {
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").split(" ").filter(Boolean);
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function stringValue(value: unknown): string | null {
if (typeof value === "number") return String(value);
return typeof value === "string" && value.trim() ? value.trim() : null;
}

View File

@ -0,0 +1,201 @@
import { Injectable } from "@nestjs/common";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
import { providerFetch } from "./provider-fetch.js";
@Injectable()
export class GoogleBooksProvider implements MetadataProvider {
readonly id = "googlebooks" as const;
async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null> {
const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10;
if (!isbn) {
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, config);
return matches[0] ?? null;
}
const matches = await this.searchVolumes(`isbn:${isbn}`, config, lookup.title, lookup.author, lookup.identifiers.isbn13);
return rankMatches(lookup.title, matches)[0] ?? null;
}
async searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise<MetadataMatch[]> {
const attempts = googleBookQueries(query);
const matches: MetadataMatch[] = [];
const seen = new Set<string>();
for (const attempt of attempts) {
for (const match of await this.searchVolumes(attempt, config, query.title, query.author, query.isbn ? toIsbn13(query.isbn) : null)) {
const key = [match.sourceId, match.isbn, match.title, match.author].filter(Boolean).join("|");
if (seen.has(key)) continue;
seen.add(key);
matches.push(match);
}
}
return rankMatches(query.title, matches);
}
private async searchVolumes(
googleQuery: string,
config: MetadataProviderConfig,
originalTitle: string,
originalAuthor: string | null,
expectedIsbn13: string | null
): Promise<MetadataMatch[]> {
const url = new URL("https://www.googleapis.com/books/v1/volumes");
url.searchParams.set("q", googleQuery);
url.searchParams.set("maxResults", "10");
url.searchParams.set("printType", "books");
if (config.apiKey) url.searchParams.set("key", config.apiKey);
const response = await providerFetch(this.id, url, { timeoutMs: 4000 });
const data = (await parseGoogleResponse(response)) as { items?: Array<{ id?: string; volumeInfo?: Record<string, unknown> }> };
return (data.items ?? [])
.map((item) => ({ sourceId: item.id, info: item.volumeInfo }))
.filter((item): item is { sourceId: string | undefined; info: Record<string, unknown> } => Boolean(item.info))
.map(({ sourceId, info }) => ({
title: stringValue(info.title) ?? undefined,
author: arrayJoin(info.authors),
description: stringValue(info.description),
language: stringValue(info.language),
publisher: stringValue(info.publisher),
publishedDate: normalizePublishedDate(stringValue(info.publishedDate)),
isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, expectedIsbn13),
coverUrl: coverUrl(info.imageLinks),
sourceId,
identifiers: { candidates: isbnCandidates(info.industryIdentifiers) },
googleRank: googleRank(originalTitle, originalAuthor, info)
}));
}
}
export class GoogleBooksProviderError extends Error {
constructor(
readonly code: "quota" | "auth" | "http",
readonly status: number,
message: string
) {
super(message);
this.name = "GoogleBooksProviderError";
}
}
async function parseGoogleResponse(response: Response): Promise<unknown> {
const data = (await response.json().catch(() => ({}))) as { error?: { message?: string; status?: string } };
if (response.ok) return data;
const message = data.error?.message ?? `Google Books HTTP ${response.status}`;
if (response.status === 429) throw new GoogleBooksProviderError("quota", response.status, message);
if (response.status === 401 || response.status === 403) throw new GoogleBooksProviderError("auth", response.status, message);
throw new GoogleBooksProviderError("http", response.status, message);
}
function googleBookQueries(query: MetadataSearchQuery): string[] {
const cleaned = cleanGoogleBooksTitle(query.title);
const relaxed = relaxSeriesTitle(cleaned);
return [
query.isbn ? `isbn:${query.isbn}` : null,
googleTitleQuery(cleaned, query.author, true),
googleTitleQuery(cleaned, query.author, false),
relaxed !== cleaned ? googleTitleQuery(relaxed, query.author, true) : null,
relaxed !== cleaned ? googleTitleQuery(relaxed, null, false) : null,
googleTitleQuery(cleaned, null, false)
].filter((value, index, values): value is string => Boolean(value) && values.indexOf(value) === index);
}
function googleTitleQuery(title: string, author: string | null, quoted: boolean): string {
const titlePart = quoted ? `intitle:"${title.replace(/"/g, " ")}"` : `intitle:${title}`;
return author ? `${titlePart}+inauthor:${author}` : titlePart;
}
function cleanGoogleBooksTitle(value: string): string {
return value
.replace(/\.[A-Za-z0-9]{2,5}$/g, " ")
.replace(/[._]+/g, " ")
.replace(/\b(FRENCH|TRUEFRENCH|MULTI|CBZ|CBR|EPUB|PDF|eBook|ebook|scan|digital)\b/gi, " ")
.replace(/\b(e?bdz|Paprika\+?|emuleCenter\.net)\b/gi, " ")
.replace(/\bT(?:ome)?\s*0?(\d{1,3})\b/gi, " $1 ")
.replace(/\bVol(?:ume)?\.?\s*0?(\d{1,3})\b/gi, " $1 ")
.replace(/[+]+/g, " ")
.replace(/\s+-\s+/g, " ")
.replace(/\s*-\s*$/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function relaxSeriesTitle(value: string): string {
return value.replace(/\b\d{1,3}\b/g, " ").replace(/\s+/g, " ").trim();
}
function rankMatches(originalTitle: string, matches: Array<MetadataMatch & { googleRank?: number }>): MetadataMatch[] {
return [...matches]
.sort((left, right) => (right.googleRank ?? 0) - (left.googleRank ?? 0))
.map(({ googleRank: _googleRank, ...match }) => match);
}
function googleRank(originalTitle: string, originalAuthor: string | null, info: Record<string, unknown>): number {
const expectedVolume = volumeNumber(originalTitle);
const candidateTitle = [stringValue(info.title), stringValue(info.subtitle)].filter(Boolean).join(" ");
let rank = tokenOverlap(cleanGoogleBooksTitle(originalTitle), candidateTitle) * 10;
if (expectedVolume) {
const candidateVolume = volumeNumber(candidateTitle);
rank += candidateVolume === expectedVolume ? 6 : candidateVolume ? -4 : 0;
}
if (originalAuthor && arrayJoin(info.authors)?.toLowerCase().includes(originalAuthor.toLowerCase())) rank += 2;
if (stringValue(info.description)) rank += 1;
if (coverUrl(info.imageLinks)) rank += 1;
return rank;
}
function volumeNumber(value: string): string | null {
return value.match(/\b(?:T|tome|vol(?:ume)?\.?)\s*0?(\d{1,3})\b/i)?.[1] ?? value.match(/\b0?(\d{1,3})\b/)?.[1] ?? null;
}
function tokenOverlap(left: string, right: string): number {
const leftTokens = new Set(normalizeTokens(left));
const rightTokens = new Set(normalizeTokens(right));
if (!leftTokens.size || !rightTokens.size) return 0;
return [...leftTokens].filter((token) => rightTokens.has(token)).length / leftTokens.size;
}
function normalizeTokens(value: string): string[] {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.split(" ")
.filter(Boolean);
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function arrayJoin(value: unknown): string | null {
return Array.isArray(value) && value.length ? value.map(String).join(", ") : null;
}
function isbnFromIndustryIdentifiers(value: unknown, expectedIsbn13: string | null): string | null {
if (!Array.isArray(value)) return null;
const entries = value as Array<{ type?: unknown; identifier?: unknown }>;
const matching = entries.find((entry) => Boolean(expectedIsbn13) && toIsbn13(stringValue(entry.identifier) ?? "") === expectedIsbn13)?.identifier;
if (matching) return stringValue(matching);
const isbn13 = entries.find((entry) => entry.type === "ISBN_13")?.identifier;
const isbn10 = entries.find((entry) => entry.type === "ISBN_10")?.identifier;
return stringValue(isbn13) ?? stringValue(isbn10);
}
function isbnCandidates(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((entry) => stringValue((entry as { identifier?: unknown }).identifier)).filter((entry): entry is string => Boolean(entry));
}
function coverUrl(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const links = value as Record<string, unknown>;
return (
stringValue(links.extraLarge) ??
stringValue(links.large) ??
stringValue(links.medium) ??
stringValue(links.thumbnail) ??
stringValue(links.smallThumbnail)
)?.replace(/^http:/, "https:") ?? null;
}

View File

@ -0,0 +1,28 @@
import { Injectable } from "@nestjs/common";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
@Injectable()
export class LocalMetadataProvider implements MetadataProvider {
readonly id = "local" as const;
async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise<MetadataMatch> {
return {
title: lookup.title,
author: lookup.author,
isbn: lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10,
identifiers: lookup.identifiers
};
}
async searchByMetadata(query: MetadataSearchQuery, _config: MetadataProviderConfig): Promise<MetadataMatch[]> {
return [
{
title: query.title,
author: query.author,
isbn: query.isbn ?? null,
publishedDate: normalizePublishedDate(query.year)
}
];
}
}

View File

@ -0,0 +1,179 @@
import { Injectable } from "@nestjs/common";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
import { extractSeriesVolume } from "../use-cases/extract-series-volume.js";
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
import { providerFetch } from "./provider-fetch.js";
@Injectable()
export class MangaDexProvider implements MetadataProvider {
readonly id = "mangadex" as const;
async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null> {
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, config);
return matches[0] ?? null;
}
async searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise<MetadataMatch[]> {
const titles = mangaDexTitleQueries(query.title);
const matches: Array<MetadataMatch & { mangaDexRank?: number }> = [];
const seen = new Map<string, number>();
for (const title of titles) {
for (const match of await searchManga(title, config)) {
const key = match.sourceId ?? `${match.title}|${match.author}`;
const ranked = { ...match, scoreTitle: title, mangaDexRank: mangaRank(query.title, title, match) };
const existingIndex = seen.get(key);
if (existingIndex === undefined) {
seen.set(key, matches.length);
matches.push(ranked);
continue;
}
if ((ranked.mangaDexRank ?? 0) > (matches[existingIndex]?.mangaDexRank ?? 0)) {
matches[existingIndex] = ranked;
}
}
}
return rankMatches(matches);
}
}
export class MangaDexProviderError extends Error {
constructor(
readonly code: "rate-limit" | "http",
readonly status: number,
message: string
) {
super(message);
this.name = "MangaDexProviderError";
}
}
async function searchManga(title: string, _config: MetadataProviderConfig): Promise<MetadataMatch[]> {
const url = new URL("https://api.mangadex.org/manga");
url.searchParams.set("title", title);
url.searchParams.set("limit", "10");
url.searchParams.set("includes[]", "cover_art");
url.searchParams.append("includes[]", "author");
url.searchParams.append("includes[]", "artist");
url.searchParams.set("contentRating[]", "safe");
url.searchParams.append("contentRating[]", "suggestive");
let response = await providerFetch("mangadex", url, {
headers: { "User-Agent": "ReadaBook/0.1 self-hosted metadata provider (MangaDex)" },
timeoutMs: 5000
});
if (response.status === 429) {
await sleep(retryDelayMs(response));
response = await providerFetch("mangadex", url, {
headers: { "User-Agent": "ReadaBook/0.1 self-hosted metadata provider (MangaDex)" },
timeoutMs: 5000
});
}
const data = (await parseMangaDexResponse(response)) as { data?: Array<Record<string, unknown>> };
return (data.data ?? []).map(mangaToMatch);
}
function retryDelayMs(response: Response): number {
const retryAfter = Number(response.headers.get("Retry-After"));
return Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1000, 2000) : 250;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function parseMangaDexResponse(response: Response): Promise<unknown> {
const data = (await response.json().catch(() => ({}))) as { errors?: Array<{ detail?: string; title?: string }> };
if (response.ok) return data;
const message = data.errors?.map((error) => error.detail ?? error.title).filter(Boolean).join("; ") || `MangaDex HTTP ${response.status}`;
if (response.status === 429) throw new MangaDexProviderError("rate-limit", response.status, message);
throw new MangaDexProviderError("http", response.status, message);
}
function mangaToMatch(manga: Record<string, unknown>): MetadataMatch & { mangaDexRank?: number } {
const id = stringValue(manga.id);
const attributes = objectValue(manga.attributes);
const relationships = Array.isArray(manga.relationships) ? (manga.relationships as Array<Record<string, unknown>>) : [];
const cover = relationships.find((entry) => entry.type === "cover_art");
const coverFile = stringValue(objectValue(cover?.attributes).fileName);
return {
title: localizedText(attributes.title) ?? undefined,
author: relationshipNames(relationships),
description: localizedText(attributes.description),
publishedDate: normalizePublishedDate(stringValue(attributes.year)),
language: stringValue(attributes.originalLanguage),
coverUrl: id && coverFile ? `https://uploads.mangadex.org/covers/${id}/${coverFile}.512.jpg` : null,
sourceId: id
};
}
function mangaDexTitleQueries(title: string): string[] {
const cleaned = extractSeriesVolume(title).seriesTitle;
return [cleaned, ...mangaDexTitleAliases(cleaned)].filter(
(value, index, values): value is string => Boolean(value) && values.indexOf(value) === index
);
}
function mangaDexTitleAliases(title: string): string[] {
const normalized = normalizeTitle(title);
if (normalized === "demon slayer school days") return ["Demon Slayer Kimetsu Academy", "Kimetsu Academy"];
return [];
}
function mangaRank(originalTitle: string, searchedTitle: string, match: MetadataMatch): number {
const expectedVolume = volumeNumber(originalTitle);
let rank = tokenOverlap(searchedTitle, match.title ?? "") * 10;
if (expectedVolume) {
const candidateVolume = volumeNumber(match.title ?? "");
rank += candidateVolume === expectedVolume ? 4 : candidateVolume ? -2 : 0;
}
if (match.coverUrl) rank += 1;
if (match.description) rank += 1;
return rank;
}
function rankMatches(matches: Array<MetadataMatch & { mangaDexRank?: number }>): MetadataMatch[] {
return [...matches].sort((left, right) => (right.mangaDexRank ?? 0) - (left.mangaDexRank ?? 0)).map(({ mangaDexRank: _rank, ...match }) => match);
}
function volumeNumber(value: string): string | null {
return value.match(/\b(?:T|tome|vol(?:ume)?\.?)\s*0?(\d{1,3})\b/i)?.[1] ?? value.match(/\b0?(\d{1,3})\b/)?.[1] ?? null;
}
function tokenOverlap(left: string, right: string): number {
const leftTokens = new Set(normalizeTitle(left).split(" ").filter(Boolean));
const rightTokens = new Set(normalizeTitle(right).split(" ").filter(Boolean));
if (!leftTokens.size || !rightTokens.size) return 0;
return [...leftTokens].filter((token) => rightTokens.has(token)).length / leftTokens.size;
}
function normalizeTitle(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function localizedText(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const entries = value as Record<string, unknown>;
return stringValue(entries.en) ?? stringValue(entries.fr) ?? Object.values(entries).map(stringValue).find(Boolean) ?? null;
}
function relationshipNames(relationships: Array<Record<string, unknown>>): string | null {
const names = relationships
.filter((entry) => entry.type === "author" || entry.type === "artist")
.map((entry) => stringValue(objectValue(entry.attributes).name))
.filter((entry): entry is string => Boolean(entry));
return names.length ? [...new Set(names)].join(", ") : null;
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function stringValue(value: unknown): string | null {
if (typeof value === "number") return String(value);
return typeof value === "string" && value.trim() ? value.trim() : null;
}

View File

@ -0,0 +1,148 @@
import { Injectable } from "@nestjs/common";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
import { providerFetch, providerHttpError } from "./provider-fetch.js";
@Injectable()
export class OpenLibraryProvider implements MetadataProvider {
readonly id = "openlibrary" as const;
async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise<MetadataMatch | null> {
const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10;
if (isbn) {
return this.lookupIsbn(isbn, lookup.identifiers.isbn13);
}
if (lookup.sourceId) {
return this.lookupEdition(lookup.sourceId, lookup.identifiers.isbn13);
}
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, _config);
return matches[0] ?? null;
}
async searchByMetadata(query: MetadataSearchQuery, _config: MetadataProviderConfig): Promise<MetadataMatch[]> {
const url = new URL("https://openlibrary.org/search.json");
url.searchParams.set("title", query.title);
if (query.author) url.searchParams.set("author", query.author);
if (query.year) url.searchParams.set("first_publish_year", query.year);
url.searchParams.set("limit", "5");
const response = await providerFetch(this.id, url, {
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
timeoutMs: 4000
});
if (!response.ok) throw await providerHttpError(this.id, response, `OpenLibrary HTTP ${response.status}`);
const data = (await response.json()) as { docs?: Array<Record<string, unknown>> };
return (data.docs ?? []).map((doc) => ({
title: stringValue(doc.title) ?? undefined,
sourceId: firstArrayValue(doc.edition_key) ?? stringValue(doc.cover_edition_key),
author: arrayJoin(doc.author_name),
language: firstArrayValue(doc.language),
publisher: firstArrayValue(doc.publisher),
publishedDate: normalizePublishedDate(String(doc.first_publish_year ?? "") || null),
isbn: bestIsbn(doc.isbn, query.isbn ? toIsbn13(query.isbn) : null),
coverUrl: openLibraryCoverUrl(doc.cover_i, firstArrayValue(doc.edition_key) ?? stringValue(doc.cover_edition_key))
}));
}
private async lookupEdition(sourceId: string, expectedIsbn13: string | null): Promise<MetadataMatch | null> {
const editionKey = sourceId.replace(/^\/?books\//, "");
if (!editionKey) return null;
const response = await providerFetch(this.id, `https://openlibrary.org/books/${encodeURIComponent(editionKey)}.json`, {
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
timeoutMs: 4000
});
if (response.status === 404) return null;
if (!response.ok) throw await providerHttpError(this.id, response, `OpenLibrary HTTP ${response.status}`);
return this.editionToMatch((await response.json()) as Record<string, unknown>, expectedIsbn13);
}
private async lookupIsbn(isbn: string, expectedIsbn13: string | null): Promise<MetadataMatch | null> {
const response = await providerFetch(this.id, `https://openlibrary.org/isbn/${encodeURIComponent(isbn)}.json`, {
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
timeoutMs: 4000
});
if (response.status === 404) return null;
if (!response.ok) throw await providerHttpError(this.id, response, `OpenLibrary HTTP ${response.status}`);
return this.editionToMatch((await response.json()) as Record<string, unknown>, expectedIsbn13);
}
private async editionToMatch(edition: Record<string, unknown>, expectedIsbn13: string | null): Promise<MetadataMatch> {
const author = await this.lookupAuthorName(edition.authors);
return {
title: stringValue(edition.title) ?? undefined,
author,
description: descriptionValue(edition.description),
isbn: bestIsbn([...(asStringArray(edition.isbn_13)), ...(asStringArray(edition.isbn_10))], expectedIsbn13),
language: languageValue(edition.languages),
publisher: firstArrayValue(edition.publishers),
publishedDate: normalizePublishedDate(stringValue(edition.publish_date)),
coverUrl: editionCoverUrl(edition)
};
}
private async lookupAuthorName(value: unknown): Promise<string | null> {
const key = (Array.isArray(value) ? value[0] : undefined)?.key;
if (typeof key !== "string") return null;
const response = await providerFetch(this.id, `https://openlibrary.org${key}.json`, {
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
timeoutMs: 3000
});
if (response.status === 404) return null;
if (!response.ok) throw await providerHttpError(this.id, response, `OpenLibrary HTTP ${response.status}`);
const author = (await response.json()) as Record<string, unknown>;
return stringValue(author.name);
}
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function firstArrayValue(value: unknown): string | null {
if (!Array.isArray(value) || !value.length) return null;
return String(value[0]);
}
function arrayJoin(value: unknown): string | null {
return Array.isArray(value) && value.length ? value.map(String).join(", ") : null;
}
function bestIsbn(value: unknown, expectedIsbn13: string | null): string | null {
if (!Array.isArray(value)) return null;
const values = value.map(String);
return (
values.find((candidate) => Boolean(expectedIsbn13) && toIsbn13(candidate) === expectedIsbn13) ??
values.find((candidate) => Boolean(toIsbn13(candidate))) ??
null
);
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function descriptionValue(value: unknown): string | null {
if (typeof value === "string") return value.trim() || null;
if (typeof value === "object" && value && "value" in value) return stringValue(value.value);
return null;
}
function languageValue(value: unknown): string | null {
const key = (Array.isArray(value) ? value[0] : undefined)?.key;
return typeof key === "string" ? key.split("/").pop() ?? null : null;
}
function openLibraryCoverUrl(coverId: unknown, editionKey: string | null): string | null {
if (typeof coverId === "number" || typeof coverId === "string") {
return `https://covers.openlibrary.org/b/id/${encodeURIComponent(String(coverId))}-L.jpg`;
}
if (editionKey) {
return `https://covers.openlibrary.org/b/olid/${encodeURIComponent(editionKey)}-L.jpg`;
}
return null;
}
function editionCoverUrl(edition: Record<string, unknown>): string | null {
const covers = Array.isArray(edition.covers) ? edition.covers : [];
return openLibraryCoverUrl(covers[0], stringValue(edition.key)?.split("/").pop() ?? null);
}

View File

@ -0,0 +1,88 @@
import { MetadataProviderId } from "../metadata.types.js";
export type MetadataProviderFailureCode = "timeout" | "dns" | "quota" | "auth" | "http" | "network";
export class MetadataProviderRequestError extends Error {
constructor(
readonly provider: MetadataProviderId | "cover",
readonly code: MetadataProviderFailureCode,
readonly message: string,
readonly status?: number
) {
super(message);
this.name = "MetadataProviderRequestError";
}
}
export async function providerFetch(
provider: MetadataProviderId | "cover",
input: string | URL,
init: RequestInit & { timeoutMs: number }
): Promise<Response> {
const { timeoutMs, ...requestInit } = init;
try {
return await fetch(input, {
...requestInit,
signal: requestInit.signal ?? AbortSignal.timeout(timeoutMs)
});
} catch (error) {
throw classifyFetchError(provider, error, timeoutMs);
}
}
export async function providerHttpError(
provider: MetadataProviderId | "cover",
response: Response,
fallbackMessage: string
): Promise<MetadataProviderRequestError> {
const message = (await response.text().catch(() => "")) || fallbackMessage;
if (response.status === 429) return new MetadataProviderRequestError(provider, "quota", message, response.status);
if (response.status === 401 || response.status === 403) return new MetadataProviderRequestError(provider, "auth", message, response.status);
return new MetadataProviderRequestError(provider, "http", message, response.status);
}
export function describeMetadataProviderError(error: unknown): string {
if (error instanceof MetadataProviderRequestError) {
const status = error.status ? ` HTTP ${error.status}` : "";
return `${error.code}${status}: ${error.message}`;
}
if (hasProviderErrorCode(error)) {
const status = typeof error.status === "number" ? ` HTTP ${error.status}` : "";
return `${String(error.code)}${status}: ${errorMessage(error)}`;
}
return errorMessage(error);
}
function classifyFetchError(provider: MetadataProviderId | "cover", error: unknown, timeoutMs: number): MetadataProviderRequestError {
const code = nestedCode(error);
if (isTimeoutError(error)) {
return new MetadataProviderRequestError(provider, "timeout", `request timed out after ${timeoutMs}ms`);
}
if (code === "EAI_AGAIN" || code === "ENOTFOUND") {
return new MetadataProviderRequestError(provider, "dns", code);
}
return new MetadataProviderRequestError(provider, "network", errorMessage(error));
}
function isTimeoutError(error: unknown): boolean {
return (
error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError") ||
error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")
);
}
function nestedCode(error: unknown): string | null {
if (!error || typeof error !== "object") return null;
const direct = "code" in error && typeof error.code === "string" ? error.code : null;
if (direct) return direct;
const cause = "cause" in error ? error.cause : null;
return cause && typeof cause === "object" && "code" in cause && typeof cause.code === "string" ? cause.code : null;
}
function hasProviderErrorCode(error: unknown): error is { code: string; status?: number; message?: string } {
return Boolean(error && typeof error === "object" && "code" in error && typeof error.code === "string");
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View File

@ -0,0 +1,32 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { ExtractIdentifiers, normalizeIsbn, toIsbn13 } from "./use-cases/extract-identifiers.js";
describe("ISBN normalization", () => {
it("accepts valid ISBN-10/13 and rejects bad checksums", () => {
expect(normalizeIsbn("0-306-40615-2")).toBe("0306406152");
expect(normalizeIsbn("978-0-306-40615-7")).toBe("9780306406157");
expect(normalizeIsbn("978-0-306-40615-8")).toBeNull();
expect(normalizeIsbn("5030931067112")).toBeNull();
});
it("converts ISBN-10 to ISBN-13", () => {
expect(toIsbn13("0-306-40615-2")).toBe("9780306406157");
});
});
describe("ExtractIdentifiers", () => {
it("uses embedded PDF text before filename fallback candidates", () => {
const dir = mkdtempSync(join(tmpdir(), "readabook-isbn-"));
const file = join(dir, "fallback 9780306406157.pdf");
writeFileSync(file, "%PDF-1.4\n1 0 obj << /Title (Book) /Subject (ISBN 0-306-40615-2) >> endobj");
const identifiers = new ExtractIdentifiers().fromMetadataAndFile({ isbn: null }, file);
expect(identifiers.isbn10).toBe("0306406152");
expect(identifiers.isbn13).toBe("9780306406157");
expect(identifiers.candidates).toContain("9780306406157");
});
});

View File

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { extractSeriesVolume, normalizeSeriesTitle } from "./use-cases/extract-series-volume.js";
describe("extractSeriesVolume", () => {
it.each([
["Daredevil 001.cbz", "Daredevil", 1, "001"],
["Daredevil 002.cbz", "Daredevil", 2, "002"],
["Daredevil - 001[Sebmov].cbz", "Daredevil", 1, "001"],
["DareDevil - 007[Fennlhor].cbz", "DareDevil", 7, "007"],
["Solo Leveling T03.cbz", "Solo Leveling", 3, "T03"],
["Solo Leveling 003.cbz", "Solo Leveling", 3, "003"],
["Solo Leveling Tome 3.cbz", "Solo Leveling", 3, "Tome 3"],
["Solo Leveling Vol. 3.cbz", "Solo Leveling", 3, "Vol 3"],
["Daredevil Issue 6.cbz", "Daredevil", 6, "Issue 6"],
["Daredevil #6.cbz", "Daredevil", 6, "#6"],
["Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz.cbz", "Eyeshield 21", 1, "T01"],
["Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz", "Dragon Ball SD", 1, "T01"],
["Demon.Slayer.School.Days.T01.FRENCH.CBZ.eBook-ebdz.cbz", "Demon Slayer School Days", 1, "T01"]
])("extracts series and volume from %s", (fileName, seriesTitle, volumeNumber, volumeLabel) => {
expect(extractSeriesVolume(seriesTitle, `/books/${fileName}`)).toMatchObject({
seriesTitle,
normalizedSeriesTitle: normalizeSeriesTitle(seriesTitle),
volumeNumber,
volumeLabel
});
});
it("keeps numeric title components that are not explicit volume markers", () => {
expect(extractSeriesVolume("Eyeshield 21")).toMatchObject({
seriesTitle: "Eyeshield 21",
normalizedSeriesTitle: "eyeshield 21",
volumeNumber: null,
volumeLabel: null
});
});
it("does not fuzzy-merge distinct normalized series titles", () => {
expect(normalizeSeriesTitle("Dragon Ball SD")).toBe("dragon ball sd");
expect(normalizeSeriesTitle("Dragon Ball")).toBe("dragon ball");
expect(normalizeSeriesTitle("Lord of the Mysteries")).toBe("lord of the mysteries");
expect(normalizeSeriesTitle("The Lord of the Rings")).toBe("the lord of the rings");
});
});

View File

@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { ExtractLocalMetadataHints } from "./use-cases/extract-local-metadata-hints.js";
import { ScoreMetadataMatch } from "./use-cases/score-metadata-match.js";
describe("local metadata hints", () => {
it("extracts title, author and year hints from a book without ISBN", () => {
const hints = new ExtractLocalMetadataHints().fromMetadataAndFile(
{
title: "Harry Potter et le Prince de Sang Mele",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
},
"/library/Harry Potter et le Prince de Sang Mele (J. K. Rowling) 2005.epub"
);
expect(hints).toMatchObject({
title: "Harry Potter et le Prince de Sang Mele",
author: "J. K. Rowling",
year: "2005",
isbn: null
});
});
});
describe("metadata match scoring", () => {
it("keeps the best remote match for locally extracted title and author", () => {
const best = new ScoreMetadataMatch().best(
{ title: "Harry Potter et le Prince de Sang Mele", author: "J. K. Rowling", year: "2005" },
[
{ title: "Harry Potter et la chambre des secrets", author: "J. K. Rowling", publishedDate: "1998" },
{ title: "Harry Potter et le Prince de sang-mêlé", author: "J.K. Rowling", publishedDate: "2005" }
]
);
expect(best?.match.title).toBe("Harry Potter et le Prince de sang-mêlé");
expect(best?.score).toBeGreaterThan(70);
});
it("scores titles, authors and dates with the contract weights", () => {
const scorer = new ScoreMetadataMatch();
const result = scorer.details(
{ title: "The Harry Potter et le prince de sang mêlé: édition collector", author: "J. K. Rowling", year: "2005" },
{
title: "Harry Potter et le prince de sang-mêlé",
author: "J.K. Rowling",
publishedDate: "2006"
}
);
expect(result.titleScore).toBe(100);
expect(result.authorScore).toBe(30);
expect(result.dateScore).toBe(5);
expect(result.score).toBe(96);
});
it("uses exact ISBN matches before weaker title-only candidates", () => {
const best = new ScoreMetadataMatch().best(
{ title: "Daredevil", author: null, isbn: "9782809476255" },
[
{
title: "Daredevil",
author: "Rosemary Carter",
isbn: "9780373105601"
},
{
title: "Daredevil by Chip Zdarsky",
author: "Chip Zdarsky",
isbn: "9782809476255"
}
]
);
expect(best?.match.author).toBe("Chip Zdarsky");
expect(best?.isbnMatch).toBe(true);
});
it("scores unrelated serialized or audiobook candidates from title/author/date only", () => {
const scorer = new ScoreMetadataMatch();
const query = { title: "Harry Potter et le prince de sang mêlé", author: "J. K. Rowling" };
const french = scorer.score(query, {
title: "Harry Potter et le prince de sang-mêlé",
author: "J. K. Rowling",
isbn: "9782070577644"
});
const koreanVolume = scorer.score(query, {
title: "Harry Potter et le prince de sang-mêlé - Volume 1",
author: "J. K. Rowling",
publisher: "문학수첩",
isbn: "9791193790724"
});
expect(french).toBeGreaterThan(80);
expect(koreanVolume).toBeLessThan(french);
});
it("does not apply legacy audiobook penalties outside the contract", () => {
const scorer = new ScoreMetadataMatch();
const query = { title: "Harry Potter et le prince de sang mêlé", author: "J. K. Rowling" };
expect(
scorer.score(query, {
title: "Harry Potter Et Le Prince De Sang-mêlé Livre Audio",
author: "J. K. Rowling",
isbn: "9782075105170"
})
).toBeGreaterThan(80);
});
});

View File

@ -0,0 +1,410 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { BnfProvider } from "./adapters/bnf.provider.js";
import { ComicVineProvider } from "./adapters/comic-vine.provider.js";
import { GoogleBooksProvider, GoogleBooksProviderError } from "./adapters/google-books.provider.js";
import { MangaDexProvider } from "./adapters/mangadex.provider.js";
import { OpenLibraryProvider } from "./adapters/open-library.provider.js";
import { MetadataProviderRequestError } from "./adapters/provider-fetch.js";
import { MetadataLookup, MetadataProviderConfig } from "./metadata.types.js";
const lookup: MetadataLookup = {
title: "Harry Potter et la Chambre des Secrets",
author: "J. K. Rowling",
filePath: "/library/HP/Harry Potter et la Chambre des Secrets (J.K. Rowling).epub",
identifiers: { isbn10: null, isbn13: "9782070612376", candidates: ["9782070612376"] },
local: {
title: "Harry Potter et la Chambre des Secrets",
author: "J. K. Rowling",
year: null,
isbn: "9782070612376",
fileTitle: "Harry Potter et la Chambre des Secrets (J.K. Rowling)",
raw: {
title: "Harry Potter et la Chambre des Secrets",
author: "J. K. Rowling",
publishedDate: null,
fileName: "Harry Potter et la Chambre des Secrets (J.K. Rowling)"
}
}
};
const config: MetadataProviderConfig = { provider: "openlibrary", enabled: true, priority: 1, apiKey: null };
afterEach(() => {
vi.unstubAllGlobals();
});
describe("metadata providers", () => {
it("queries OpenLibrary by ISBN and normalizes the matching book", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({
title: "Harry Potter et la Chambre des Secrets",
authors: [{ key: "/authors/OL23919A" }],
languages: [{ key: "/languages/fre" }],
publishers: ["Gallimard jeunesse"],
publish_date: "2007-03",
isbn_13: ["9782070612376"],
isbn_10: ["2070612379"]
})
)
.mockResolvedValueOnce(jsonResponse({ name: "J. K. Rowling" }));
vi.stubGlobal("fetch", fetchMock);
const result = await new OpenLibraryProvider().lookup(lookup, config);
expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toBe("https://openlibrary.org/isbn/9782070612376.json");
expect(result).toMatchObject({
title: "Harry Potter et la Chambre des Secrets",
author: "J. K. Rowling",
isbn: "9782070612376"
});
});
it("reports Google Books quota exhaustion explicitly", async () => {
const fetchMock = vi.fn(async () => jsonResponse({ error: { code: 429, status: "RESOURCE_EXHAUSTED" } }, 429));
vi.stubGlobal("fetch", fetchMock);
await expect(new GoogleBooksProvider().lookup(lookup, { ...config, provider: "googlebooks" })).rejects.toMatchObject({
code: "quota",
status: 429
} satisfies Partial<GoogleBooksProviderError>);
expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toContain("q=isbn%3A9782070612376");
});
it("classifies provider DNS failures explicitly", async () => {
const error = new TypeError("fetch failed") as Error & { cause?: { code: string } };
error.cause = { code: "EAI_AGAIN" };
vi.stubGlobal("fetch", vi.fn(async () => Promise.reject(error)));
await expect(new OpenLibraryProvider().searchByMetadata({ title: "Daredevil", author: null }, config)).rejects.toMatchObject({
code: "dns",
message: "EAI_AGAIN"
} satisfies Partial<MetadataProviderRequestError>);
});
it.each([
["Demon.Slayer.School.Days.T01.FRENCH.CBZ.eBook-ebdz", "Demon Slayer School Days 1"],
["Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+", "Dragon Ball SD 1"],
["Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz", "Eyeshield 21 1"],
["Solo Leveling T03", "Solo Leveling 3"]
])("cleans noisy Google Books title queries for %s", async (title, expectedCleanTitle) => {
const fetchMock = vi.fn(async () => jsonResponse({ totalItems: 0, items: [] }));
vi.stubGlobal("fetch", fetchMock);
await new GoogleBooksProvider().searchByMetadata({ title, author: null }, { ...config, provider: "googlebooks" });
const queries = fetchMock.mock.calls.map((call) => new URL(String((call as unknown[])[0])).searchParams.get("q") ?? "");
expect(queries[0]).toBe(`intitle:"${expectedCleanTitle}"`);
expect(queries.join(" ")).not.toMatch(/\b(FRENCH|CBZ|eBook|ebdz|Paprika)\b/i);
});
it("sorts Google Books results by matching manga volume instead of taking the first item", async () => {
const fetchMock = vi.fn(async () =>
jsonResponse({
totalItems: 2,
items: [
{ id: "volume-2", volumeInfo: { title: "Solo Leveling, Vol. 2", authors: ["Chugong"], publishedDate: "2021" } },
{ id: "volume-3", volumeInfo: { title: "Solo Leveling, Vol. 3", authors: ["Chugong"], publishedDate: "2021" } }
]
})
);
vi.stubGlobal("fetch", fetchMock);
const results = await new GoogleBooksProvider().searchByMetadata(
{ title: "Solo Leveling T03", author: null },
{ ...config, provider: "googlebooks" }
);
expect(results[0]).toMatchObject({ title: "Solo Leveling, Vol. 3", sourceId: "volume-3" });
});
it.each([
["Solo Leveling T03", "Solo Leveling"],
["Solo Leveling 003", "Solo Leveling"],
["Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz", "Eyeshield 21"],
["Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+", "Dragon Ball SD"]
])("queries MangaDex with the cleaned series title for %s", async (title, expectedQuery) => {
const fetchMock = vi.fn(async () => jsonResponse({ data: [] }));
vi.stubGlobal("fetch", fetchMock);
await new MangaDexProvider().searchByMetadata({ title, author: null }, { ...config, provider: "mangadex" });
const firstUrl = new URL(String((fetchMock.mock.calls[0] as unknown[])[0]));
expect(firstUrl.searchParams.get("title")).toBe(expectedQuery);
});
it("queries MangaDex aliases and maps cover_art to a cover URL", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ data: [] }))
.mockResolvedValueOnce(
jsonResponse({
data: [
{
id: "manga-1",
attributes: {
title: { en: "Demon Slayer: Kimetsu Academy" },
description: { en: "School spin-off." },
year: 2021,
originalLanguage: "ja"
},
relationships: [
{ type: "cover_art", attributes: { fileName: "cover.jpg" } },
{ type: "author", attributes: { name: "Natsuki Hokami" } }
]
}
]
})
)
.mockResolvedValue(jsonResponse({ data: [] }));
vi.stubGlobal("fetch", fetchMock);
const results = await new MangaDexProvider().searchByMetadata(
{ title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ.eBook-ebdz", author: null },
{ ...config, provider: "mangadex" }
);
const firstUrl = new URL(String((fetchMock.mock.calls[0] as unknown[])[0]));
const secondUrl = new URL(String((fetchMock.mock.calls[1] as unknown[])[0]));
expect(firstUrl.searchParams.get("title")).toBe("Demon Slayer School Days");
expect(secondUrl.searchParams.get("title")).toBe("Demon Slayer Kimetsu Academy");
expect(results[0]).toMatchObject({
title: "Demon Slayer: Kimetsu Academy",
scoreTitle: "Demon Slayer Kimetsu Academy",
author: "Natsuki Hokami",
publishedDate: "2021",
coverUrl: "https://uploads.mangadex.org/covers/manga-1/cover.jpg.512.jpg"
});
});
it("keeps Dragon Ball SD ahead of Dragon Ball for MangaDex matches", async () => {
const fetchMock = vi.fn(async () =>
jsonResponse({
data: [
{
id: "dragon-ball",
attributes: { title: { en: "Dragon Ball" }, description: { en: "Original series." }, year: 1984, originalLanguage: "ja" },
relationships: []
},
{
id: "dragon-ball-sd",
attributes: { title: { en: "Dragon Ball SD" }, description: { en: "SD spin-off." }, year: 2010, originalLanguage: "ja" },
relationships: []
}
]
})
);
vi.stubGlobal("fetch", fetchMock);
const results = await new MangaDexProvider().searchByMetadata(
{ title: "Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+", author: null },
{ ...config, provider: "mangadex" }
);
expect(results[0]).toMatchObject({ title: "Dragon Ball SD", sourceId: "dragon-ball-sd" });
});
it("reports MangaDex rate limits explicitly", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ errors: [{ detail: "Too many requests" }] }, 429)));
await expect(
new MangaDexProvider().searchByMetadata({ title: "Solo Leveling T03", author: null }, { ...config, provider: "mangadex" })
).rejects.toMatchObject({ code: "rate-limit", status: 429 });
});
it("requires a Comic Vine API key before querying", async () => {
await expect(
new ComicVineProvider().searchByMetadata({ title: "Wolverine Origin", author: null }, { ...config, provider: "comicvine", apiKey: null })
).rejects.toMatchObject({ code: "missing-key" });
});
it("queries Comic Vine volumes/issues and cleans HTML descriptions", async () => {
const fetchMock = vi.fn(async () =>
jsonResponse({
status_code: 1,
results: [
{
id: 123,
name: "Wolverine: The Origin",
description: "<p>Origin story &amp; family secrets.</p>",
start_year: "2001",
image: { super_url: "https://comicvine.gamespot.com/a/uploads/scale_large/origin.jpg" },
publisher: { name: "Marvel" }
}
]
})
);
vi.stubGlobal("fetch", fetchMock);
const results = await new ComicVineProvider().searchByMetadata(
{ title: "Comics.Fr.Wolverine.Origin.by.AleK.(emuleCenter.net)", author: null },
{ ...config, provider: "comicvine", apiKey: "cv-key" }
);
const firstUrl = new URL(String((fetchMock.mock.calls[0] as unknown[])[0]));
expect(firstUrl.searchParams.get("resources")).toBe("volume");
expect(firstUrl.searchParams.get("query")).toBe("Comics Fr Wolverine Origin by AleK");
expect(results[0]).toMatchObject({
title: "Wolverine: The Origin",
description: "Origin story & family secrets.",
publishedDate: "2001",
publisher: "Marvel",
coverUrl: "https://comicvine.gamespot.com/a/uploads/scale_large/origin.jpg"
});
});
it("reports Comic Vine invalid keys and rate limits explicitly", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ status_code: 101, error: "Invalid API Key" })));
await expect(
new ComicVineProvider().searchByMetadata({ title: "Daredevil", author: null }, { ...config, provider: "comicvine", apiKey: "bad" })
).rejects.toMatchObject({ code: "invalid-key" });
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ error: "Rate limited" }, 429)));
await expect(
new ComicVineProvider().searchByMetadata({ title: "Daredevil", author: null }, { ...config, provider: "comicvine", apiKey: "ok" })
).rejects.toMatchObject({ code: "rate-limit", status: 429 });
});
it("queries OpenLibrary by local metadata when ISBN is missing", async () => {
const fetchMock = vi.fn(async () =>
jsonResponse({
docs: [
{
title: "Harry Potter et le Prince de sang-mêlé",
author_name: ["J. K. Rowling"],
first_publish_year: 2005,
publisher: ["Gallimard jeunesse"],
cover_edition_key: "OL24333986M",
isbn: ["9782070612383"]
}
]
})
);
vi.stubGlobal("fetch", fetchMock);
const result = await new OpenLibraryProvider().searchByMetadata(
{ title: "Harry Potter et le Prince de Sang Mele", author: "J. K. Rowling", year: "2005" },
config
);
const url = new URL(String((fetchMock.mock.calls[0] as unknown[])[0]));
expect(url.searchParams.get("title")).toBe("Harry Potter et le Prince de Sang Mele");
expect(url.searchParams.get("author")).toBe("J. K. Rowling");
expect(result[0]).toMatchObject({
title: "Harry Potter et le Prince de sang-mêlé",
sourceId: "OL24333986M",
author: "J. K. Rowling",
publishedDate: "2005"
});
});
it("looks up OpenLibrary edition details from a search result source id", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({
title: "Harry Potter et le prince de sang-mele",
authors: [{ key: "/authors/OL23919A" }],
languages: [{ key: "/languages/fre" }],
publishers: ["Gallimard jeunesse"],
publish_date: "2005",
isbn_13: ["9782070612383"],
description: { value: "Sixième année à Poudlard." }
})
)
.mockResolvedValueOnce(jsonResponse({ name: "J. K. Rowling" }));
vi.stubGlobal("fetch", fetchMock);
const result = await new OpenLibraryProvider().lookup({ ...lookup, sourceId: "OL24333986M", identifiers: { isbn10: null, isbn13: null, candidates: [] } }, config);
expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toBe("https://openlibrary.org/books/OL24333986M.json");
expect(result).toMatchObject({
title: "Harry Potter et le prince de sang-mele",
author: "J. K. Rowling",
isbn: "9782070612383",
description: "Sixième année à Poudlard."
});
});
it("parses BnF SRU UNIMARC records returned for ISBN lookup", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
textResponse(`<?xml version="1.0" encoding="UTF-8"?>
<srw:searchRetrieveResponse xmlns:srw="http://www.loc.gov/zing/srw/">
<srw:records><srw:record><srw:recordData>
<mxc:record xmlns:mxc="info:lc/xmlns/marcxchange-v2">
<mxc:datafield tag="073"><mxc:subfield code="a">9782070612376</mxc:subfield></mxc:datafield>
<mxc:datafield tag="101"><mxc:subfield code="a">fre</mxc:subfield></mxc:datafield>
<mxc:datafield tag="200">
<mxc:subfield code="a">Harry Potter et la chambre des secrets</mxc:subfield>
<mxc:subfield code="f">J. K. Rowling</mxc:subfield>
</mxc:datafield>
<mxc:datafield tag="210">
<mxc:subfield code="c">Gallimard jeunesse</mxc:subfield>
<mxc:subfield code="d">DL 2007</mxc:subfield>
</mxc:datafield>
<mxc:datafield tag="330"><mxc:subfield code="a">Résumé BnF.</mxc:subfield></mxc:datafield>
</mxc:record>
</srw:recordData></srw:record></srw:records>
</srw:searchRetrieveResponse>`)
)
);
const result = await new BnfProvider().lookup(lookup, { ...config, provider: "bnf" });
expect(result).toMatchObject({
title: "Harry Potter et la chambre des secrets",
author: "J. K. Rowling",
publisher: "Gallimard jeunesse",
publishedDate: "2007",
isbn: "9782070612376"
});
});
it("prefers BnF title search records with a valid book ISBN over non-book EAN records", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
textResponse(`<?xml version="1.0" encoding="UTF-8"?>
<srw:searchRetrieveResponse xmlns:srw="http://www.loc.gov/zing/srw/">
<srw:records>
<srw:record><srw:recordData>
<mxc:record xmlns:mxc="info:lc/xmlns/marcxchange-v2">
<mxc:datafield tag="073"><mxc:subfield code="a">5030931067112</mxc:subfield></mxc:datafield>
<mxc:datafield tag="200"><mxc:subfield code="a">Harry Potter et le prince de sang-mêlé</mxc:subfield><mxc:subfield code="f">Electronic arts</mxc:subfield></mxc:datafield>
</mxc:record>
</srw:recordData></srw:record>
<srw:record><srw:recordData>
<mxc:record xmlns:mxc="info:lc/xmlns/marcxchange-v2">
<mxc:datafield tag="010"><mxc:subfield code="a">274419736X</mxc:subfield></mxc:datafield>
<mxc:datafield tag="200"><mxc:subfield code="a">Harry Potter et le prince de sang-mêlé</mxc:subfield><mxc:subfield code="f">J. K. Rowling</mxc:subfield></mxc:datafield>
</mxc:record>
</srw:recordData></srw:record>
</srw:records>
</srw:searchRetrieveResponse>`)
)
);
const result = await new BnfProvider().searchByMetadata(
{ title: "Harry Potter et le prince de sang mele", author: null },
{ ...config, provider: "bnf" }
);
expect(result[0]).toMatchObject({
author: "J. K. Rowling",
isbn: "274419736X"
});
expect(result[1]?.isbn).toBeNull();
});
});
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
function textResponse(body: string, status = 200): Response {
return new Response(body, { status, headers: { "content-type": "application/xml" } });
}

View File

@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module.js";
import { BnfProvider } from "./adapters/bnf.provider.js";
import { ComicVineProvider } from "./adapters/comic-vine.provider.js";
import { GoogleBooksProvider } from "./adapters/google-books.provider.js";
import { LocalMetadataProvider } from "./adapters/local.provider.js";
import { MangaDexProvider } from "./adapters/mangadex.provider.js";
import { OpenLibraryProvider } from "./adapters/open-library.provider.js";
import { MetadataService } from "./metadata.service.js";
@Module({
imports: [DatabaseModule],
providers: [MetadataService, LocalMetadataProvider, OpenLibraryProvider, GoogleBooksProvider, BnfProvider, MangaDexProvider, ComicVineProvider],
exports: [MetadataService]
})
export class MetadataModule {}

View File

@ -0,0 +1,923 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DatabaseService } from "../database/database.service.js";
import { books, libraries } from "../database/schema.js";
import { BookMetadata } from "../scanner/metadata.js";
import { MetadataProviderRequestError } from "./adapters/provider-fetch.js";
import { MetadataService } from "./metadata.service.js";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "./metadata.types.js";
const previousDatabasePath = process.env.DATABASE_PATH;
const previousStorageDir = process.env.STORAGE_DIR;
const tempDirs: string[] = [];
afterEach(() => {
process.env.DATABASE_PATH = previousDatabasePath;
process.env.STORAGE_DIR = previousStorageDir;
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("MetadataService", () => {
it.runIf(canLoadBetterSqlite())("backfills description from provider lookup after a metadata search hit yields an ISBN", async () => {
const database = createDatabase();
const localProvider = providerStub("local");
const openLibrarySearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise<MetadataMatch[]>>(async () => [
{
title: "Harry Potter et le Prince de sang-mêlé",
author: "J. K. Rowling",
isbn: "9782070612383",
publishedDate: "2005"
}
]);
const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise<MetadataMatch | null>>(async (lookup) => {
if (lookup.identifiers.isbn13 !== "9782070612383") return null;
return {
title: "Harry Potter et le Prince de sang-mêlé",
author: "J. K. Rowling",
isbn: "9782070612383",
publishedDate: "2005",
description: "Harry Potter découvre l'héritage du Prince de Sang-Mêlé."
};
});
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
searchByMetadata: openLibrarySearch,
lookup: openLibraryLookup
};
const service = new MetadataService(
database,
localProvider as never,
openLibraryProvider as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const localMetadata: BookMetadata = {
title: "Harry Potter et le Prince de Sang Mele",
author: "J. K. Rowling",
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
};
const result = await service.enrichMetadata(localMetadata, "/library/HP/Harry Potter et le Prince de Sang Mele.epub", {
remote: true
});
expect(openLibrarySearch).toHaveBeenCalledOnce();
expect(openLibraryLookup).toHaveBeenCalledOnce();
expect(openLibraryLookup.mock.calls[0]?.[0].identifiers.isbn13).toBe("9782070612383");
expect(result).toMatchObject({
title: "Harry Potter et le Prince de Sang Mele",
isbn: "9782070612383",
isbn13: "9782070612383",
description: "Harry Potter découvre l'héritage du Prince de Sang-Mêlé."
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("continues enrichment after a provider DNS failure and logs the failure class", async () => {
const database = createDatabase();
database.sqlite.prepare("UPDATE metadata_source_config SET enabled = 1 WHERE provider = 'googlebooks'").run();
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
lookup: async () => null,
searchByMetadata: async () => {
throw new MetadataProviderRequestError("openlibrary", "dns", "EAI_AGAIN");
}
};
const googleProvider: MetadataProvider = {
id: "googlebooks",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Daredevil",
author: "Roy Thomas",
description: "Daredevil keeps moving even when another provider is unreachable.",
publishedDate: "2019"
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
openLibraryProvider as never,
googleProvider as never,
providerStub("bnf") as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const result = await service.enrichMetadata(
{
title: "Daredevil",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
},
"/library/Daredevil.cbz",
{ remote: true }
);
expect(warn).toHaveBeenCalledWith('[metadata] Provider openlibrary failed for "Daredevil": dns: EAI_AGAIN');
expect(result).toMatchObject({
title: "Daredevil",
author: "Roy Thomas",
description: "Daredevil keeps moving even when another provider is unreachable."
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("looks up details from a title search hit even when the hit has no ISBN", async () => {
const database = createDatabase();
const localProvider = providerStub("local");
const openLibrarySearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise<MetadataMatch[]>>(async () => [
{
title: "Harry Potter et le prince de sang-mele",
sourceId: "OL24333986M",
publishedDate: "2005"
}
]);
const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise<MetadataMatch | null>>(async (lookup) => {
if (lookup.sourceId !== "OL24333986M") return null;
return {
title: "Harry Potter et le prince de sang-mêlé",
author: "J. K. Rowling",
isbn: "9782070612383",
publishedDate: "2005",
description: "Sixième année à Poudlard."
};
});
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
searchByMetadata: openLibrarySearch,
lookup: openLibraryLookup
};
const service = new MetadataService(
database,
localProvider as never,
openLibraryProvider as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const localMetadata: BookMetadata = {
title: "Harry Potter et le prince de sang mele",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
};
const result = await service.enrichMetadata(localMetadata, "/library/Harry Potter et le prince de sang mele.epub", {
remote: true
});
expect(openLibrarySearch).toHaveBeenCalledOnce();
expect(openLibraryLookup).toHaveBeenCalledOnce();
expect(openLibraryLookup.mock.calls[0]?.[0].sourceId).toBe("OL24333986M");
expect(result).toMatchObject({
title: "Harry Potter et le prince de sang mele",
author: "J. K. Rowling",
isbn: "9782070612383",
isbn13: "9782070612383",
description: "Sixième année à Poudlard."
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("backfills a missing ISBN lookup description from a high-confidence title search", async () => {
const database = createDatabase();
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
lookup: async () => ({
title: "Harry Potter et la coupe de feu",
author: "J. K. Rowling",
isbn: "9782070624553",
publisher: "Gallimard",
publishedDate: "2016"
}),
searchByMetadata: async () => [
{
title: "Harry Potter et la coupe de feu",
author: "J. K. Rowling",
isbn: "9782070619207",
description: "Harry est invité à assister à la Coupe du monde de Quidditch."
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
openLibraryProvider as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const result = await service.enrichMetadata(
{
title: "Harry Potter et la coupe de feu",
author: "J. K. Rowling",
description: null,
isbn: "9782070624553",
language: null,
publisher: null,
publishedDate: null,
coverPath: "/covers/local.jpg"
},
"/library/Harry Potter et la coupe de feu.epub",
{ remote: true }
);
expect(result).toMatchObject({
isbn: "9782070624553",
description: "Harry est invité à assister à la Coupe du monde de Quidditch.",
coverPath: "/covers/local.jpg"
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("uses provider priority as the tie-breaker for high-confidence title matches", async () => {
const database = createDatabase();
database.sqlite.prepare("UPDATE metadata_source_config SET enabled = 1 WHERE provider = 'bnf'").run();
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Daredevil",
author: "Rosemary Carter",
isbn: "9780373105601",
publisher: "Harlequin Books",
publishedDate: "1982"
}
]
};
const bnfProvider: MetadataProvider = {
id: "bnf",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Daredevil",
author: "scénario, Roy Thomas, Gary Friedrich",
isbn: "9782809476255",
description: "Daredevil affronte l'Homme aux échasses.",
language: "fre",
publisher: "Panini comics",
publishedDate: "2019"
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
openLibraryProvider as never,
providerStub("googlebooks") as never,
bnfProvider as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const localMetadata: BookMetadata = {
title: "Daredevil",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
};
const result = await service.enrichMetadata(localMetadata, "/library/Daredevil.cbz", { remote: true });
expect(result).toMatchObject({
title: "Daredevil",
author: "Rosemary Carter",
isbn: "9780373105601",
isbn13: "9780373105601",
description: "Daredevil affronte l'Homme aux échasses."
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("re-enriches from stored local hints instead of a previously failed remote ISBN", async () => {
const database = createDatabase();
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Comics", path: "/library", enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const localMetadataJson = JSON.stringify({
title: "Daredevil",
author: null,
year: null,
isbn: null,
fileTitle: "Daredevil",
raw: { title: "Daredevil", author: null, publishedDate: null, fileName: "Daredevil" }
});
const book = database.db
.insert(books)
.values({
libraryId: library.id,
title: "Daredevil",
author: "Rosemary Carter",
description: null,
isbn: "9780373105601",
isbn13: "9780373105601",
identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }),
localMetadataJson,
language: null,
publisher: "Harlequin Books",
publishedDate: "1982",
format: "cbz",
filePath: "/library/Daredevil.cbz",
coverPath: "/covers/daredevil.jpg",
scanStatus: "succeeded",
enrichmentStatus: "failed",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.returning()
.get();
const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise<MetadataMatch | null>>(async () => null);
const bnfSearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise<MetadataMatch[]>>(async () => [
{
title: "Daredevil",
author: "scénario, Roy Thomas, Gary Friedrich",
isbn: "9782809476255",
description: "Daredevil affronte l'Homme aux échasses.",
publisher: "Panini comics",
publishedDate: "2019"
}
]);
database.sqlite.prepare("UPDATE metadata_source_config SET enabled = 1 WHERE provider = 'bnf'").run();
const service = new MetadataService(
database,
providerStub("local") as never,
{ id: "openlibrary", lookup: openLibraryLookup, searchByMetadata: async () => [] } as never,
providerStub("googlebooks") as never,
{ id: "bnf", lookup: async () => null, searchByMetadata: bnfSearch } as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const result = await service.enrichBook(book.id);
expect(openLibraryLookup).not.toHaveBeenCalled();
expect(bnfSearch.mock.calls[0]?.[0].title).toBe("Daredevil");
expect(result).toMatchObject({
author: "Rosemary Carter",
isbn: "9780373105601",
isbn13: "9780373105601",
publisher: "Harlequin Books",
coverPath: "/covers/daredevil.jpg"
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("stores provider covers as local bytes and exposes field provenance with metadata status", async () => {
const database = createDatabase();
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
headers: new Headers({ "content-type": "image/jpeg" }),
arrayBuffer: async () => new Uint8Array([1, 2, 3, 4]).buffer
} as Response);
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Daredevil",
author: "Roy Thomas",
description: "Daredevil affronte une nouvelle menace.",
isbn: "9782809476255",
coverUrl: "https://covers.openlibrary.org/b/id/123-L.jpg"
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
openLibraryProvider as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const result = await service.enrichMetadata(
{
title: "Daredevil",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
},
"/library/Daredevil.cbz",
{ remote: true }
);
expect(fetchMock).toHaveBeenCalledWith("https://covers.openlibrary.org/b/id/123-L.jpg", expect.any(Object));
expect(result.coverPath).toMatch(/storage\/covers\/.+\.jpg$/);
expect(result.metadataStatus).toBe("enriched");
expect(JSON.parse(result.metadataProvenanceJson)).toMatchObject({
title: "local",
author: "openlibrary",
description: "openlibrary",
coverPath: "openlibrary"
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("retrofits a local cover for an existing book during metadata enrichment", async () => {
const database = createDatabase();
mkdirSync(database.config.storageDir, { recursive: true });
const filePath = join(database.config.storageDir, "Demon.Slayer.School.Days.T01.FRENCH.CBZ");
const zip = new AdmZip();
zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
zip.writeZip(filePath);
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Comics", path: database.config.storageDir, enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const book = database.db
.insert(books)
.values({
libraryId: library.id,
title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz",
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }),
localMetadataJson: JSON.stringify({
title: "Demon Slayer School Days T01 FRENCH",
author: null,
year: null,
isbn: null,
fileTitle: "Demon Slayer School Days T01 FRENCH",
raw: { title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz", author: null, publishedDate: null, fileName: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz" }
}),
language: null,
publisher: null,
publishedDate: null,
format: "cbz",
filePath,
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: JSON.stringify({ title: "local" }),
scanStatus: "succeeded",
enrichmentStatus: "succeeded",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.returning()
.get();
const service = new MetadataService(
database,
providerStub("local") as never,
providerStub("openlibrary") as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const result = await service.enrichBook(book.id);
expect(result.coverPath).toMatch(/covers\/[a-f0-9]+\.jpg$/);
expect(result.coverPath && existsSync(result.coverPath)).toBe(true);
expect(result.metadataStatus).toBe("partial");
expect(JSON.parse(result.metadataProvenanceJson ?? "{}")).toMatchObject({ coverPath: "local" });
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("drops sentinel publication dates from provider matches for real affected titles", async () => {
const database = createDatabase();
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Harry Potter et les reliques de la mort",
author: "J. K. Rowling",
publishedDate: "0101-01-01T00:00:00+00:00",
description: "Septième année."
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
openLibraryProvider as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const result = await service.enrichMetadata(
{
title: "Harry Potter et les reliques de la mort",
author: "J. K. Rowling",
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
},
"/library/Harry Potter et les reliques de la mort.epub",
{ remote: true }
);
expect(result.publishedDate).toBeNull();
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("does not overwrite an existing valid date with a provider sentinel", async () => {
const database = createDatabase();
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Novels", path: "/library", enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const book = database.db
.insert(books)
.values({
libraryId: library.id,
title: "Lord of the Mysteries",
author: "Cuttlefish That Loves Diving",
description: null,
isbn: null,
isbn13: null,
identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }),
localMetadataJson: JSON.stringify({
title: "Lord of the Mysteries",
author: "Cuttlefish That Loves Diving",
year: null,
isbn: null,
fileTitle: "Lord of the Mysteries",
raw: { title: "Lord of the Mysteries", author: "Cuttlefish That Loves Diving", publishedDate: null, fileName: "Lord of the Mysteries" }
}),
language: null,
publisher: null,
publishedDate: "2018",
format: "epub",
filePath: "/library/Lord of the Mysteries.epub",
coverPath: null,
metadataStatus: "partial",
metadataProvenanceJson: JSON.stringify({ publishedDate: "existing" }),
scanStatus: "succeeded",
enrichmentStatus: "succeeded",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.returning()
.get();
const openLibraryProvider: MetadataProvider = {
id: "openlibrary",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Lord of the Mysteries",
author: "Cuttlefish That Loves Diving",
publishedDate: "0101-01-01T00:00:00+00:00",
description: "A mysterious sequence begins."
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
openLibraryProvider as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
providerStub("mangadex") as never,
providerStub("comicvine") as never
);
const result = await service.enrichBook(book.id);
expect(result.publishedDate).toBe("2018");
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("records MangaDex provenance and stores its cover locally", async () => {
const database = createDatabase();
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
headers: new Headers({ "content-type": "image/jpeg" }),
arrayBuffer: async () => new Uint8Array([9, 8, 7]).buffer
} as Response);
const mangaDexProvider: MetadataProvider = {
id: "mangadex",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Solo Leveling",
author: "Chugong",
description: "A hunter levels up alone.",
publishedDate: "2018",
coverUrl: "https://uploads.mangadex.org/covers/manga-1/cover.jpg.512.jpg"
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
providerStub("openlibrary") as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
mangaDexProvider as never,
providerStub("comicvine") as never
);
const result = await service.enrichMetadata(
{
title: "Solo Leveling T03",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
},
"/library/Solo Leveling T03.cbz",
{ remote: true }
);
expect(fetchMock).toHaveBeenCalledWith("https://uploads.mangadex.org/covers/manga-1/cover.jpg.512.jpg", expect.any(Object));
expect(JSON.parse(result.metadataProvenanceJson)).toMatchObject({
author: "mangadex",
description: "mangadex",
publishedDate: "mangadex",
coverPath: "mangadex"
});
expect(result.coverPath).toMatch(/storage\/covers\/.+\.jpg$/);
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("scores MangaDex matches against the cleaned series title instead of the noisy archive title", async () => {
const database = createDatabase();
const mangaDexProvider: MetadataProvider = {
id: "mangadex",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Dragon Ball SD",
author: "Naho Ooishi",
description: "A super-deformed Dragon Ball spin-off.",
publishedDate: "2010",
sourceId: "dragon-ball-sd"
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
providerStub("openlibrary") as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
mangaDexProvider as never,
providerStub("comicvine") as never
);
const result = await service.enrichMetadata(
{
title: "Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
},
"/library/Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz",
{ remote: true }
);
expect(result).toMatchObject({
title: "Dragon Ball SD",
author: "Naho Ooishi",
description: "A super-deformed Dragon Ball spin-off.",
publishedDate: "2010"
});
expect(JSON.parse(result.metadataProvenanceJson)).toMatchObject({
title: "local",
author: "mangadex",
description: "mangadex"
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("accepts MangaDex alias matches through the provider score title", async () => {
const database = createDatabase();
const mangaDexProvider: MetadataProvider = {
id: "mangadex",
lookup: async () => null,
searchByMetadata: async () => [
{
title: "Demon Slayer: Kimetsu Academy",
scoreTitle: "Demon Slayer Kimetsu Academy",
author: "Natsuki Hokami",
description: "School spin-off.",
publishedDate: "2021",
sourceId: "kimetsu-academy"
}
]
};
const service = new MetadataService(
database,
providerStub("local") as never,
providerStub("openlibrary") as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
mangaDexProvider as never,
providerStub("comicvine") as never
);
const result = await service.enrichMetadata(
{
title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz",
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
},
"/library/Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz.cbz",
{ remote: true }
);
expect(result).toMatchObject({
title: "Demon Slayer School Days",
author: "Natsuki Hokami",
description: "School spin-off.",
publishedDate: "2021"
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("re-enriches existing manga with the cleaned series title on the live book path", async () => {
const database = createDatabase();
mkdirSync(database.config.storageDir, { recursive: true });
const filePath = join(database.config.storageDir, "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz.cbz");
const zip = new AdmZip();
zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
zip.writeZip(filePath);
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Manga", path: database.config.storageDir, enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const book = database.db
.insert(books)
.values({
libraryId: library.id,
title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz",
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: JSON.stringify({ isbn10: null, isbn13: null, candidates: [] }),
localMetadataJson: JSON.stringify({
title: "Demon Slayer School Days T01 FRENCH",
author: null,
year: null,
isbn: null,
fileTitle: "Demon Slayer School Days T01 FRENCH",
raw: {
title: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz",
author: null,
publishedDate: null,
fileName: "Demon.Slayer.School.Days.T01.FRENCH.CBZ. -ebdz"
}
}),
language: null,
publisher: null,
publishedDate: null,
format: "cbz",
filePath,
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: JSON.stringify({ title: "local" }),
scanStatus: "succeeded",
enrichmentStatus: "succeeded",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.returning()
.get();
const mangaDexSearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise<MetadataMatch[]>>(async () => [
{
title: "Demon Slayer School Days",
author: "Natsuki Hokami",
description: "School spin-off.",
publishedDate: "2021",
sourceId: "demon-slayer-school-days"
}
]);
const service = new MetadataService(
database,
providerStub("local") as never,
providerStub("openlibrary") as never,
providerStub("googlebooks") as never,
providerStub("bnf") as never,
{ id: "mangadex", lookup: async () => null, searchByMetadata: mangaDexSearch } as never,
providerStub("comicvine") as never
);
const result = await service.enrichBook(book.id);
expect(mangaDexSearch.mock.calls[0]?.[0].title).toBe("Demon Slayer School Days");
expect(result).toMatchObject({
title: "Demon Slayer School Days",
author: "Natsuki Hokami",
description: "School spin-off.",
publishedDate: "2021"
});
database.onModuleDestroy();
});
});
function createDatabase(): DatabaseService {
const dir = mkdtempSync(join(tmpdir(), "readabook-metadata-service-"));
tempDirs.push(dir);
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
process.env.STORAGE_DIR = join(dir, "storage");
return new DatabaseService();
}
function providerStub(id: MetadataProvider["id"]): MetadataProvider {
return {
id,
lookup: async () => null,
searchByMetadata: async () => []
};
}
function canLoadBetterSqlite(): boolean {
try {
const database = createDatabase();
database.onModuleDestroy();
return true;
} catch {
return false;
}
}

View File

@ -0,0 +1,627 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, extname, join } from "node:path";
import { eq } from "drizzle-orm";
import {
MetadataSourcesConfigDto,
UpdateMetadataSourcesConfigDto
} from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { automationSettings, books, metadataSourceConfig, series } from "../database/schema.js";
import { BookMetadata, extractMetadata } from "../scanner/metadata.js";
import { BnfProvider } from "./adapters/bnf.provider.js";
import { ComicVineProvider } from "./adapters/comic-vine.provider.js";
import { GoogleBooksProvider } from "./adapters/google-books.provider.js";
import { LocalMetadataProvider } from "./adapters/local.provider.js";
import { MangaDexProvider } from "./adapters/mangadex.provider.js";
import { OpenLibraryProvider } from "./adapters/open-library.provider.js";
import { describeMetadataProviderError, providerFetch } from "./adapters/provider-fetch.js";
import {
BookIdentifiers,
LocalMetadataHints,
MetadataField,
MetadataMatch,
MetadataProvider,
MetadataProviderConfig,
MetadataProviderId,
MetadataProvenance,
MetadataSearchQuery,
MetadataStatus
} from "./metadata.types.js";
import { ExtractIdentifiers, toIsbn13 } from "./use-cases/extract-identifiers.js";
import { ExtractLocalMetadataHints } from "./use-cases/extract-local-metadata-hints.js";
import { extractSeriesVolume } from "./use-cases/extract-series-volume.js";
import { normalizePublishedDate } from "./use-cases/normalize-published-date.js";
import { ResolveProviderChain } from "./use-cases/resolve-provider-chain.js";
import { ScoredMetadataMatch, ScoreMetadataMatch } from "./use-cases/score-metadata-match.js";
type ProviderCandidate = ScoredMetadataMatch & {
provider: MetadataProviderId;
priority: number;
};
@Injectable()
export class MetadataService {
private readonly extractIdentifiers = new ExtractIdentifiers();
private readonly extractLocalMetadataHints = new ExtractLocalMetadataHints();
private readonly scoreMetadataMatch = new ScoreMetadataMatch();
private readonly resolveProviderChain: ResolveProviderChain;
constructor(
private readonly database: DatabaseService,
local: LocalMetadataProvider,
openLibrary: OpenLibraryProvider,
googleBooks: GoogleBooksProvider,
bnf: BnfProvider,
mangaDex: MangaDexProvider,
comicVine: ComicVineProvider
) {
this.resolveProviderChain = new ResolveProviderChain([local, openLibrary, googleBooks, bnf, mangaDex, comicVine]);
}
getSourcesConfig(): MetadataSourcesConfigDto {
const settings = this.getAutomationRow();
return {
isbnPriorityEnabled: Boolean(settings.isbnPriorityEnabled),
sources: this.getProviderConfigs().map((source) => ({
provider: source.provider,
enabled: source.provider === "local" ? true : source.enabled,
priority: source.priority,
hasApiKey: Boolean(source.apiKey)
}))
};
}
updateSourcesConfig(input: UpdateMetadataSourcesConfigDto): MetadataSourcesConfigDto {
const now = this.database.now();
if (input.isbnPriorityEnabled !== undefined) {
this.database.db
.update(automationSettings)
.set({ isbnPriorityEnabled: input.isbnPriorityEnabled, updatedAt: now })
.where(eq(automationSettings.id, 1))
.run();
}
for (const source of input.sources ?? []) {
if ((source.provider as string) === "local") {
throw new BadRequestException("Local metadata source is always active and cannot be updated");
}
const values: Partial<typeof metadataSourceConfig.$inferInsert> = {
enabled: source.enabled,
priority: source.priority,
updatedAt: now
};
if (source.apiKey !== undefined) values.apiKey = source.apiKey;
this.database.db.update(metadataSourceConfig).set(values).where(eq(metadataSourceConfig.provider, source.provider)).run();
}
return this.getSourcesConfig();
}
async enrichMetadata(
localMetadata: BookMetadata,
filePath: string,
options: { remote: boolean }
): Promise<
BookMetadata & {
isbn13: string | null;
identifiersJson: string;
localMetadataJson: string;
metadataStatus: MetadataStatus;
metadataProvenanceJson: string;
}
> {
const identifiers = this.extractIdentifiers.fromMetadataAndFile(localMetadata, filePath);
const local = this.extractLocalMetadataHints.fromMetadataAndFile(localMetadata, filePath);
const configs = this.getProviderConfigs();
const chain = options.remote
? this.resolveProviderChain.resolve(configs)
: this.resolveProviderChain.resolve(configs).filter((entry) => entry.provider.id === "local");
const candidates: ProviderCandidate[] = [];
const query = this.buildSearchQuery(local, identifiers, filePath);
for (const { provider, config } of chain) {
if (provider.id === "local") continue;
try {
const hasIsbn = Boolean(identifiers.isbn13 ?? identifiers.isbn10);
const match = hasIsbn
? await provider.lookup(
{
title: local.title,
author: local.author,
filePath,
sourceId: null,
identifiers,
local
},
config
)
: null;
if (match) {
const completedMatch = match.description
? match
: mergeMetadataMatch(match, await this.searchMissingDescription(provider, config, local, identifiers, filePath));
candidates.push(scoreProviderCandidate(this.scoreMetadataMatch, query, completedMatch, provider.id, config.priority));
continue;
}
if (!options.remote) continue;
const matches = await provider.searchByMetadata(query, config);
const best = this.scoreMetadataMatch.best(query, matches);
if (!best && matches.length) {
console.info(
`[metadata] Provider ${provider.id} returned ${matches.length} result(s) rejected by scoring for "${query.title}"`
);
}
if (best) {
if (!isActionableSearchMatch(best.match)) continue;
let providerMatch = best.match;
const detailedMatch = await this.lookupSearchMatchDetails(provider, config, filePath, identifiers, local, best.match);
if (detailedMatch) providerMatch = mergeMetadataMatch(detailedMatch, best.match);
candidates.push(scoreProviderCandidate(this.scoreMetadataMatch, query, providerMatch, provider.id, config.priority));
}
} catch (error) {
console.warn(`[metadata] Provider ${provider.id} failed for "${query.title}": ${describeMetadataProviderError(error)}`);
}
}
const materializedCandidates = await this.materializeQualifiedCovers(candidates, filePath);
const localProvenance = provenanceFromLocal(localMetadata);
const { metadata: merged, provenance: remoteProvenance } = mergeCandidatesWithLocal(
materializedCandidates,
localMetadata,
identifiers,
query.title
);
let provenance: MetadataProvenance = { ...localProvenance, ...remoteProvenance };
if (merged.isbn && !provenance.isbn) provenance.isbn = "local";
const isbn13 = identifiers.isbn13 ?? (merged.isbn ? toIsbn13(merged.isbn) : null);
const metadataStatus = computeMetadataStatus(merged);
return {
...merged,
isbn: merged.isbn ?? isbn13 ?? identifiers.isbn10,
isbn13,
identifiersJson: JSON.stringify(identifiers),
localMetadataJson: JSON.stringify(local),
metadataStatus,
metadataProvenanceJson: JSON.stringify(provenance)
};
}
async enrichBook(bookId: number): Promise<typeof books.$inferSelect> {
const book = this.database.db.select().from(books).where(eq(books.id, bookId)).get();
if (!book) throw new NotFoundException("Book not found");
const local = parseStoredLocalMetadata(book.localMetadataJson);
const metadata = await this.extractCurrentLocalMetadata(book, local);
const enriched = await this.enrichMetadata(metadata, book.filePath, { remote: true });
const next = preserveExistingWhenMissing(enriched, book, book.filePath);
const seriesInfo = this.resolveSeries(next.title, book.filePath);
return this.database.db
.update(books)
.set({
seriesId: seriesInfo.seriesId,
title: next.title,
author: next.author,
description: next.description,
isbn: next.isbn,
isbn13: next.isbn13,
identifiersJson: enriched.identifiersJson,
localMetadataJson: enriched.localMetadataJson,
language: next.language,
publisher: next.publisher,
publishedDate: next.publishedDate,
volumeNumber: seriesInfo.volumeNumber,
volumeLabel: seriesInfo.volumeLabel,
coverPath: next.coverPath,
metadataStatus: next.metadataStatus,
metadataProvenanceJson: next.metadataProvenanceJson,
updatedAt: this.database.now()
})
.where(eq(books.id, book.id))
.returning()
.get();
}
private async extractCurrentLocalMetadata(book: typeof books.$inferSelect, local: LocalMetadataHints | null): Promise<BookMetadata> {
const fallback: BookMetadata = {
title: local?.title ?? book.title,
author: local ? local.author : book.author,
description: null,
isbn: local ? local.isbn : book.isbn,
language: null,
publisher: book.publisher,
publishedDate: normalizePublishedDate(local ? local.year : book.publishedDate),
coverPath: book.coverPath
};
if (!existsSync(book.filePath)) return fallback;
try {
const extracted = await extractMetadata(book.filePath, this.database.config.storageDir);
return {
title: extracted.title || fallback.title,
author: extracted.author ?? fallback.author,
description: extracted.description ?? fallback.description,
isbn: extracted.isbn ?? fallback.isbn,
language: extracted.language ?? fallback.language,
publisher: extracted.publisher ?? fallback.publisher,
publishedDate: normalizePublishedDate(extracted.publishedDate) ?? fallback.publishedDate,
coverPath: extracted.coverPath ?? fallback.coverPath
};
} catch {
return fallback;
}
}
private resolveSeries(title: string, filePath: string): { seriesId: number; volumeNumber: number | null; volumeLabel: string | null } {
const parsed = extractSeriesVolume(title, filePath);
const now = this.database.now();
const row = this.database.db
.insert(series)
.values({
title: parsed.seriesTitle,
normalizedTitle: parsed.normalizedSeriesTitle,
description: null,
publisher: null,
createdAt: now,
updatedAt: now
})
.onConflictDoUpdate({
target: series.normalizedTitle,
set: { title: parsed.seriesTitle, updatedAt: now }
})
.returning({ id: series.id })
.get();
return { seriesId: row.id, volumeNumber: parsed.volumeNumber, volumeLabel: parsed.volumeLabel };
}
private getProviderConfigs(): MetadataProviderConfig[] {
return this.database.db
.select()
.from(metadataSourceConfig)
.all()
.map((row) => ({
provider: row.provider,
enabled: row.provider === "local" ? true : row.enabled,
priority: row.provider === "local" ? 0 : row.priority,
apiKey: row.apiKey
}));
}
private getAutomationRow(): typeof automationSettings.$inferSelect {
return this.database.db.select().from(automationSettings).where(eq(automationSettings.id, 1)).get()!;
}
private async lookupSearchMatchDetails(
provider: MetadataProvider,
config: MetadataProviderConfig,
filePath: string,
identifiers: BookIdentifiers,
local: LocalMetadataHints,
match: MetadataMatch
): Promise<MetadataMatch | null> {
const derivedIdentifiers = {
...identifiers,
isbn13: identifiers.isbn13 ?? (match.isbn ? toIsbn13(match.isbn) : null),
isbn10: identifiers.isbn10 ?? match.isbn ?? null,
candidates: [...new Set([...identifiers.candidates, ...(match.isbn ? [match.isbn] : [])])]
};
const hasNewIdentifier = derivedIdentifiers.isbn13 !== identifiers.isbn13 || derivedIdentifiers.isbn10 !== identifiers.isbn10;
const hasLookupTarget = hasNewIdentifier || Boolean(match.sourceId);
if (!hasLookupTarget) return null;
return provider.lookup(
{
title: match.title ?? local.title,
author: match.author ?? local.author,
filePath,
sourceId: match.sourceId,
identifiers: derivedIdentifiers,
local
},
config
);
}
private async searchMissingDescription(
provider: MetadataProvider,
config: MetadataProviderConfig,
local: LocalMetadataHints,
identifiers: BookIdentifiers,
filePath: string
): Promise<MetadataMatch | null> {
const query = this.buildSearchQuery(local, identifiers, filePath);
const matches = await provider.searchByMetadata(query, config);
return (
matches
.map((match) => ({ match, score: this.scoreMetadataMatch.score(query, match) }))
.filter((entry) => entry.match.description && entry.score >= 75)
.sort((left, right) => right.score - left.score)[0]?.match ?? null
);
}
private buildSearchQuery(local: LocalMetadataHints, identifiers: BookIdentifiers, filePath: string): MetadataSearchQuery {
return {
title: extractSeriesVolume(local.title, filePath).seriesTitle,
author: local.author,
year: local.year,
isbn: identifiers.isbn13 ?? identifiers.isbn10 ?? local.isbn
};
}
private async materializeCover(match: MetadataMatch, filePath: string, provider: MetadataProviderId): Promise<MetadataMatch> {
if (match.coverPath || !match.coverUrl) return match;
try {
const response = await providerFetch("cover", match.coverUrl, { timeoutMs: 5000 });
if (!response.ok) return match;
const data = Buffer.from(await response.arrayBuffer());
if (!data.length) return match;
const extension = coverExtension(match.coverUrl, response.headers.get("content-type"));
const hash = createHash("sha256").update(`${filePath}:${provider}:${match.coverUrl}`).digest("hex").slice(0, 24);
const target = join(this.database.config.storageDir, "covers", `${hash}${extension}`);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, data);
return { ...match, coverPath: target };
} catch {
return match;
}
}
private async materializeQualifiedCovers(candidates: ProviderCandidate[], filePath: string): Promise<ProviderCandidate[]> {
const materialized: ProviderCandidate[] = [];
for (const candidate of candidates) {
if (isQualifiedSourceCover(candidate)) {
materialized.push({
...candidate,
match: await this.materializeCover(candidate.match, filePath, candidate.provider)
});
} else {
materialized.push(candidate);
}
}
return materialized;
}
}
function isActionableSearchMatch(match: MetadataMatch): boolean {
return Boolean(match.isbn ?? match.sourceId ?? match.description);
}
function mergeMetadataMatch(current: MetadataMatch, next: MetadataMatch | null): MetadataMatch {
if (!next) return current;
return {
title: current.title ?? next.title,
author: current.author ?? next.author,
description: current.description ?? next.description,
isbn: current.isbn ?? next.isbn,
language: current.language ?? next.language,
publisher: current.publisher ?? next.publisher,
publishedDate: normalizePublishedDate(current.publishedDate) ?? normalizePublishedDate(next.publishedDate),
coverPath: current.coverPath ?? next.coverPath,
coverUrl: current.coverUrl ?? next.coverUrl,
sourceId: current.sourceId ?? next.sourceId,
identifiers: current.identifiers ?? next.identifiers
};
}
const metadataFields: MetadataField[] = ["title", "author", "description", "isbn", "language", "publisher", "publishedDate", "coverPath"];
const fillableMetadataFields: MetadataField[] = ["author", "description", "isbn", "language", "publisher", "publishedDate"];
function scoreProviderCandidate(
scorer: ScoreMetadataMatch,
query: MetadataSearchQuery,
match: MetadataMatch,
provider: MetadataProviderId,
priority: number
): ProviderCandidate {
return { ...scorer.details(query, match), provider, priority };
}
function mergeCandidatesWithLocal(
candidates: ProviderCandidate[],
local: BookMetadata,
identifiers: BookIdentifiers,
title: string
): { metadata: BookMetadata; provenance: MetadataProvenance } {
const sorted = [...candidates].sort(compareProviderCandidates);
const retained = sorted[0] ?? null;
const completionOrder = [...(retained ? [retained] : []), ...sorted.filter((candidate) => candidate !== retained)];
const metadata: BookMetadata = {
title,
author: local.author ?? null,
description: local.description ?? null,
isbn: identifiers.isbn13 ?? identifiers.isbn10 ?? local.isbn ?? null,
language: local.language ?? null,
publisher: local.publisher ?? null,
publishedDate: normalizePublishedDate(local.publishedDate),
coverPath: local.coverPath ?? null
};
const provenance: MetadataProvenance = {};
for (const field of fillableMetadataFields) {
if (hasMetadataValue(metadata[field])) continue;
const source = completionOrder.find((candidate) => hasMetadataValue(normalizeCandidateField(candidate.match, field)));
if (!source) continue;
metadata[field] = normalizeCandidateField(source.match, field) as never;
provenance[field] = source.provider;
provenance[`${field}Score` as MetadataField] = String(source.score) as never;
}
const coverSource = completionOrder.find((candidate) => isQualifiedSourceCover(candidate) && hasMetadataValue(candidate.match.coverPath));
if (coverSource && shouldUseSourceCover(metadata.coverPath)) {
metadata.coverPath = coverSource.match.coverPath ?? null;
provenance.coverPath = coverSource.provider;
provenance.coverPathScore = String(coverSource.score) as never;
}
return { metadata, provenance };
}
function compareProviderCandidates(left: ProviderCandidate, right: ProviderCandidate): number {
if (left.isbnMatch !== right.isbnMatch) return left.isbnMatch ? -1 : 1;
const leftHighConfidence = isHighConfidenceSelection(left);
const rightHighConfidence = isHighConfidenceSelection(right);
if (leftHighConfidence && rightHighConfidence) return left.priority - right.priority;
if (leftHighConfidence !== rightHighConfidence) return leftHighConfidence ? -1 : 1;
if (left.score !== right.score) return right.score - left.score;
return left.priority - right.priority;
}
function isHighConfidenceSelection(candidate: ProviderCandidate): boolean {
return candidate.titleScore > 90 && (candidate.authorScore == null || candidate.authorScore >= 15);
}
function isQualifiedSourceCover(candidate: ProviderCandidate): boolean {
return candidate.score >= 80 && candidate.titleScore >= 85 && Boolean(candidate.match.coverPath ?? candidate.match.coverUrl);
}
function shouldUseSourceCover(currentCoverPath: string | null): boolean {
return !hasMetadataValue(currentCoverPath) || isLocalCoverPath(currentCoverPath);
}
function isLocalCoverPath(value: string): boolean {
return /[/\\]covers[/\\][a-f0-9]{24}\.[a-z0-9]+$/i.test(value);
}
function normalizeCandidateField(match: MetadataMatch, field: MetadataField): string | null {
if (field === "publishedDate") return normalizePublishedDate(match.publishedDate);
return match[field] ?? null;
}
function hasMetadataValue(value: string | null | undefined): value is string {
return Boolean(value && value.trim());
}
function provenanceFromLocal(local: BookMetadata): MetadataProvenance {
const provenance: MetadataProvenance = {};
for (const field of metadataFields) {
if (local[field] != null && local[field] !== "") provenance[field] = "local";
}
return provenance;
}
function computeMetadataStatus(metadata: Pick<BookMetadata, "author" | "description" | "isbn" | "language" | "publisher" | "publishedDate" | "coverPath">): MetadataStatus {
const hasCover = Boolean(metadata.coverPath);
const filled = [metadata.author, metadata.description, metadata.isbn, metadata.language, metadata.publisher, metadata.publishedDate].filter(Boolean).length;
if (hasCover && filled >= 2) return "enriched";
if (hasCover || filled > 0) return "partial";
return "none";
}
function mergeExistingProvenance(
enriched: BookMetadata & { metadataProvenanceJson: string },
existing: typeof books.$inferSelect,
finalValues: BookMetadata & { isbn13: string | null }
): MetadataProvenance {
const next = parseProvenance(enriched.metadataProvenanceJson);
const previous = parseProvenance(existing.metadataProvenanceJson);
const provenance: MetadataProvenance = { ...previous };
for (const field of metadataFields) {
if (field === "title") {
if (finalValues.title && !provenance.title) {
provenance.title = finalValues.title === enriched.title && finalValues.title !== existing.title ? (next.title ?? "local") : (previous.title ?? "existing");
}
continue;
}
if (field === "publishedDate") {
const finalDate = normalizePublishedDate(finalValues.publishedDate);
if (finalDate && finalDate === normalizePublishedDate(enriched.publishedDate) && finalDate !== normalizePublishedDate(existing.publishedDate)) {
provenance[field] = next[field] ?? provenance[field];
copyScoreProvenance(next, provenance, field);
} else if (finalDate && !provenance[field]) {
provenance[field] = previous[field] ?? "existing";
}
continue;
}
if (field === "coverPath" && finalValues.coverPath && finalValues.coverPath === enriched.coverPath && finalValues.coverPath !== existing.coverPath) {
provenance.coverPath = next.coverPath ?? provenance.coverPath;
copyScoreProvenance(next, provenance, field);
continue;
}
if (finalValues[field] && finalValues[field] === enriched[field] && finalValues[field] !== existing[field]) {
provenance[field] = next[field] ?? provenance[field];
copyScoreProvenance(next, provenance, field);
continue;
}
if (finalValues[field] != null && existing[field] != null && !provenance[field]) {
provenance[field] = previous[field] ?? "existing";
}
}
return provenance;
}
function copyScoreProvenance(source: MetadataProvenance, target: MetadataProvenance, field: MetadataField): void {
const scoreKey = `${field}Score`;
if (source[scoreKey]) target[scoreKey] = source[scoreKey];
}
function parseProvenance(value: string | null): MetadataProvenance {
if (!value) return {};
try {
const parsed = JSON.parse(value) as MetadataProvenance;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function coverExtension(url: string, contentType: string | null): string {
if (contentType?.includes("png")) return ".png";
if (contentType?.includes("webp")) return ".webp";
if (contentType?.includes("gif")) return ".gif";
const fromUrl = extname(new URL(url).pathname).toLowerCase();
return fromUrl === ".png" || fromUrl === ".webp" || fromUrl === ".gif" || fromUrl === ".jpg" || fromUrl === ".jpeg" ? fromUrl : ".jpg";
}
function parseStoredLocalMetadata(value: string | null): LocalMetadataHints | null {
if (!value) return null;
try {
const parsed = JSON.parse(value) as Partial<LocalMetadataHints>;
return typeof parsed.title === "string" ? (parsed as LocalMetadataHints) : null;
} catch {
return null;
}
}
function preserveExistingWhenMissing(
enriched: BookMetadata & {
isbn13: string | null;
identifiersJson: string;
localMetadataJson: string;
metadataStatus: MetadataStatus;
metadataProvenanceJson: string;
},
existing: typeof books.$inferSelect,
filePath: string
): BookMetadata & { isbn13: string | null; metadataStatus: MetadataStatus; metadataProvenanceJson: string } {
const previousProvenance = parseProvenance(existing.metadataProvenanceJson);
const next = {
title: chooseTitle(existing.title, enriched.title, filePath),
author: existing.author ?? enriched.author,
description: existing.description ?? enriched.description,
isbn: existing.isbn ?? enriched.isbn,
isbn13: existing.isbn13 ?? enriched.isbn13,
language: existing.language ?? enriched.language,
publisher: existing.publisher ?? enriched.publisher,
publishedDate: normalizePublishedDate(existing.publishedDate) ?? normalizePublishedDate(enriched.publishedDate),
coverPath: chooseCoverPath(existing.coverPath, enriched.coverPath, previousProvenance)
};
const provenance = mergeExistingProvenance(enriched, existing, next);
return {
...next,
metadataStatus: computeMetadataStatus(next),
metadataProvenanceJson: JSON.stringify(provenance)
};
}
function chooseCoverPath(existingCoverPath: string | null, enrichedCoverPath: string | null, previousProvenance: MetadataProvenance): string | null {
if (!existingCoverPath) return enrichedCoverPath;
if (!enrichedCoverPath || enrichedCoverPath === existingCoverPath) return existingCoverPath;
return canReplaceExistingCover(existingCoverPath, previousProvenance) ? enrichedCoverPath : existingCoverPath;
}
function chooseTitle(existingTitle: string, enrichedTitle: string, filePath: string): string {
if (!enrichedTitle) return existingTitle;
if (!existingTitle) return enrichedTitle;
const parsedExisting = extractSeriesVolume(existingTitle, filePath).seriesTitle;
return parsedExisting === enrichedTitle && existingTitle !== enrichedTitle ? enrichedTitle : existingTitle;
}
function canReplaceExistingCover(existingCoverPath: string, previousProvenance: MetadataProvenance): boolean {
const provenance = previousProvenance.coverPath;
return (provenance == null || provenance === "local" || provenance === "existing") && isLocalCoverPath(existingCoverPath);
}

View File

@ -0,0 +1,65 @@
import { BookMetadata } from "../scanner/metadata.js";
export type MetadataProviderId = "local" | "openlibrary" | "googlebooks" | "bnf" | "mangadex" | "comicvine";
export type BookIdentifiers = {
isbn10: string | null;
isbn13: string | null;
candidates: string[];
};
export type MetadataLookup = {
title: string;
author: string | null;
filePath: string;
sourceId?: string | null;
identifiers: BookIdentifiers;
local: LocalMetadataHints;
};
export type LocalMetadataHints = {
title: string;
author: string | null;
year: string | null;
isbn: string | null;
fileTitle: string;
raw: {
title: string;
author: string | null;
publishedDate: string | null;
fileName: string;
};
};
export type MetadataSearchQuery = {
title: string;
author: string | null;
year?: string | null;
isbn?: string | null;
};
export type MetadataMatch = Partial<BookMetadata> & {
sourceId?: string | null;
coverUrl?: string | null;
identifiers?: Partial<BookIdentifiers>;
scoreTitle?: string | null;
};
export type MetadataField = "title" | "author" | "description" | "isbn" | "language" | "publisher" | "publishedDate" | "coverPath";
export type MetadataStatus = "enriched" | "partial" | "none";
export type MetadataProvenance = Partial<Record<string, MetadataProviderId | "existing" | string>>;
export type MetadataProviderConfig = {
provider: MetadataProviderId;
enabled: boolean;
priority: number;
apiKey: string | null;
};
export interface MetadataProvider {
readonly id: MetadataProviderId;
lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null>;
searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise<MetadataMatch[]>;
}

View File

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { normalizePublishedDate } from "./use-cases/normalize-published-date.js";
describe("normalizePublishedDate", () => {
it("rejects sentinel and absurd dates seen in real metadata providers", () => {
expect(normalizePublishedDate("0101-01-01T00:00:00+00:00")).toBeNull();
expect(normalizePublishedDate("0001-01-01")).toBeNull();
expect(normalizePublishedDate("1970-01-01")).toBeNull();
expect(normalizePublishedDate("0000")).toBeNull();
});
it("keeps only credible supported date formats", () => {
expect(normalizePublishedDate("2007")).toBe("2007");
expect(normalizePublishedDate("2007-07")).toBe("2007-07");
expect(normalizePublishedDate("2007-07-21")).toBe("2007-07-21");
expect(normalizePublishedDate("2007-07-21T00:00:00+00:00")).toBe("2007-07-21");
});
it("rejects years outside the supported publication range", () => {
expect(normalizePublishedDate("1499")).toBeNull();
expect(normalizePublishedDate("2028")).toBeNull();
});
});

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { MetadataProvider } from "./metadata.types.js";
import { ResolveProviderChain } from "./use-cases/resolve-provider-chain.js";
const provider = (id: MetadataProvider["id"]): MetadataProvider => ({
id,
lookup: async () => null,
searchByMetadata: async () => []
});
describe("ResolveProviderChain", () => {
it("keeps local active and orders enabled remote providers by priority", () => {
const chain = new ResolveProviderChain([provider("googlebooks"), provider("local"), provider("bnf")]).resolve([
{ provider: "local", enabled: false, priority: 99, apiKey: null },
{ provider: "googlebooks", enabled: true, priority: 2, apiKey: null },
{ provider: "bnf", enabled: true, priority: 1, apiKey: null }
]);
expect(chain.map((entry) => entry.provider.id)).toEqual(["bnf", "googlebooks", "local"]);
});
});

View File

@ -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<string>();
for (const value of [metadata.isbn, basename(filePath, extname(filePath))]) {
for (const isbn of findIsbns(String(value ?? ""))) candidates.add(isbn);
}
const extension = extname(filePath).toLowerCase();
if (extension === ".epub") {
for (const isbn of findIsbns(readLimitedEpubText(filePath))) candidates.add(isbn);
}
if (extension === ".pdf") {
const buffer = readFileSync(filePath);
const head = buffer.subarray(0, Math.min(buffer.length, 256 * 1024)).toString("latin1");
for (const isbn of findIsbns(head)) candidates.add(isbn);
}
return normalizeCandidates([...candidates]);
}
}
export function normalizeIsbn(value: string): string | null {
const compact = value.replace(/[^0-9X]/gi, "").toUpperCase();
if (compact.length === 10 && isValidIsbn10(compact)) return compact;
if (compact.length === 13 && /^97[89]/.test(compact) && isValidIsbn13(compact)) return compact;
return null;
}
export function toIsbn13(value: string): string | null {
const isbn = normalizeIsbn(value);
if (!isbn) return null;
if (isbn.length === 13) return isbn;
const stem = `978${isbn.slice(0, 9)}`;
let sum = 0;
for (let index = 0; index < stem.length; index += 1) {
sum += Number(stem[index]) * (index % 2 === 0 ? 1 : 3);
}
return `${stem}${(10 - (sum % 10)) % 10}`;
}
function normalizeCandidates(values: string[]): ExtractedIdentifiers {
const normalized = [...new Set(values.map(normalizeIsbn).filter((value): value is string => Boolean(value)))];
const isbn13 = normalized.map(toIsbn13).find((value): value is string => Boolean(value)) ?? null;
const isbn10 = normalized.find((value) => value.length === 10) ?? null;
return { isbn10, isbn13, candidates: normalized };
}
function findIsbns(text: string): string[] {
const matches = text.match(/(?:ISBN(?:-1[03])?:?\s*)?(?:97[89][-\s]?)?(?:\d[-\s]?){9,12}[\dX]/gi) ?? [];
return matches.map((match) => match.replace(/^ISBN(?:-1[03])?:?\s*/i, ""));
}
function isValidIsbn10(value: string): boolean {
let sum = 0;
for (let index = 0; index < 10; index += 1) {
const char = value[index];
const digit = char === "X" && index === 9 ? 10 : Number(char);
if (!Number.isInteger(digit)) return false;
sum += digit * (10 - index);
}
return sum % 11 === 0;
}
function isValidIsbn13(value: string): boolean {
let sum = 0;
for (let index = 0; index < 13; index += 1) {
const digit = Number(value[index]);
if (!Number.isInteger(digit)) return false;
sum += digit * (index % 2 === 0 ? 1 : 3);
}
return sum % 10 === 0;
}
function readLimitedEpubText(filePath: string): string {
try {
const zip = new AdmZip(filePath);
const fragments: string[] = [zip.readAsText("META-INF/container.xml")];
for (const entry of zip.getEntries()) {
if (fragments.join("").length > 256 * 1024) break;
if (!entry.isDirectory && /\.(opf|xhtml|html|htm|xml)$/i.test(entry.entryName)) {
fragments.push(stripXml(zip.readAsText(entry)));
}
}
return fragments.join("\n");
} catch {
return "";
}
}
function stripXml(value: string): string {
try {
const parsed = xmlParser.parse(value);
return JSON.stringify(parsed).slice(0, 256 * 1024);
} catch {
return value.slice(0, 256 * 1024);
}
}

View File

@ -0,0 +1,66 @@
import { basename, extname } from "node:path";
import { BookMetadata } from "../../scanner/metadata.js";
import { LocalMetadataHints } from "../metadata.types.js";
export class ExtractLocalMetadataHints {
fromMetadataAndFile(metadata: BookMetadata, filePath: string): LocalMetadataHints {
const fileName = basename(filePath, extname(filePath));
const parsed = parseFileName(fileName);
const title = cleanTitle(metadata.title) || parsed.title || fileName;
const author = cleanValue(metadata.author) ?? parsed.author;
const year = yearFrom(metadata.publishedDate) ?? parsed.year;
return {
title,
author,
year,
isbn: cleanValue(metadata.isbn),
fileTitle: parsed.title ?? fileName,
raw: {
title: metadata.title,
author: metadata.author,
publishedDate: metadata.publishedDate,
fileName
}
};
}
}
function parseFileName(fileName: string): { title: string | null; author: string | null; year: string | null } {
let value = fileName.replace(/[_]+/g, " ").replace(/\s+/g, " ").trim();
const year = yearFrom(value);
if (year) value = value.replace(new RegExp(`\\b${year}\\b`), " ");
const parenthetical = [...value.matchAll(/\(([^()]{2,120})\)/g)].map((match) => match[1].trim());
const authorFromParentheses = parenthetical.find((item) => looksLikeAuthor(item)) ?? null;
value = value.replace(/\([^()]*\)/g, " ");
const split = value.match(/^(.+?)\s+-\s+(.+)$/);
const title = cleanTitle(split?.[1] ?? value);
const author = cleanValue(split?.[2]) ?? authorFromParentheses;
return { title, author, year };
}
function cleanTitle(value: string | null): string | null {
if (!value) return null;
const cleaned = value
.replace(/\[[^\]]*\]/g, " ")
.replace(/\b(epub|pdf|retail|ebook|scan)\b/gi, " ")
.replace(/\s+/g, " ")
.trim();
return cleaned || null;
}
function cleanValue(value: string | null | undefined): string | null {
if (!value) return null;
const cleaned = value.replace(/\s+/g, " ").trim();
return cleaned || null;
}
function yearFrom(value: string | null): string | null {
return value?.match(/\b(1[5-9]\d{2}|20\d{2})\b/)?.[1] ?? null;
}
function looksLikeAuthor(value: string): boolean {
return /[A-Za-zÀ-ÖØ-öø-ÿ]/.test(value) && (value.includes(".") || value.includes(" ") || /^[A-Z][a-z]+$/.test(value));
}

View File

@ -0,0 +1,67 @@
import { basename, extname } from "node:path";
export type SeriesVolume = {
seriesTitle: string;
normalizedSeriesTitle: string;
volumeNumber: number | null;
volumeLabel: string | null;
};
export function extractSeriesVolume(title: string, filePath?: string | null): SeriesVolume {
const source = cleanSeriesSource(filePath ? basename(filePath, extname(filePath)) : title) || cleanSeriesSource(title) || title;
const explicit = source.match(/\b(?:T(?:ome)?|Vol(?:ume)?\.?|Issue|No\.?)\s*0*(\d{1,4})\b/i) ?? source.match(/#\s*0*(\d{1,4})\b/);
if (explicit?.[1]) {
return result(source.replace(explicit[0], " "), Number(explicit[1]), explicit[0].trim());
}
const padded = source.match(/\b(0{1,3}\d{1,4})\b\s*$/);
if (padded?.[1]) {
return result(source.slice(0, padded.index).trim(), Number(padded[1]), padded[1]);
}
return result(source, null, null);
}
function result(seriesTitle: string, volumeNumber: number | null, volumeLabel: string | null): SeriesVolume {
const title = cleanSeriesTitle(seriesTitle);
return {
seriesTitle: title,
normalizedSeriesTitle: normalizeSeriesTitle(title),
volumeNumber: Number.isFinite(volumeNumber) && volumeNumber !== null ? volumeNumber : null,
volumeLabel
};
}
function cleanSeriesSource(value: string): string {
return value
.replace(/\.[A-Za-z0-9]{2,5}$/g, " ")
.replace(/\[[^\]]*\]/g, " ")
.replace(/[._]+/g, " ")
.replace(/\b(FRENCH|TRUEFRENCH|MULTI|CBZ|CBR|EPUB|PDF|eBook|ebook|scan|digital|retail)\b/gi, " ")
.replace(/\b(e?bdz|Paprika\+?|emuleCenter(?:\.|\s+)net)\b/gi, " ")
.replace(/[+]+/g, " ")
.replace(/\s+-\s+/g, " ")
.replace(/\s*-\s*$/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function cleanSeriesTitle(value: string): string {
return (
value
.replace(/\([^)]*\)/g, " ")
.replace(/\bby\s+[A-Za-z0-9À-ÖØ-öø-ÿ.' -]{2,80}$/i, " ")
.replace(/\s+/g, " ")
.trim() || "Untitled Series"
);
}
export function normalizeSeriesTitle(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}

View File

@ -0,0 +1,44 @@
const minimumYear = 1500;
const maximumYear = 2027;
const rejectedExactDates = new Set(["0001-01-01", "0101-01-01", "1970-01-01"]);
export function normalizePublishedDate(value: string | null | undefined): string | null {
const text = value?.trim();
if (!text) return null;
const isoDate = text.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T\s].*)?$/);
if (isoDate) {
const [, year, month, day] = isoDate;
const date = `${year}-${month}-${day}`;
if (rejectedExactDates.has(date)) return null;
return validDate(Number(year), Number(month), Number(day)) ? date : null;
}
const yearMonth = text.match(/^(\d{4})-(\d{2})$/);
if (yearMonth) {
const [, year, month] = yearMonth;
return validYear(Number(year)) && validMonth(Number(month)) ? `${year}-${month}` : null;
}
const yearOnly = text.match(/^(\d{4})$/);
if (yearOnly) {
const year = Number(yearOnly[1]);
return validYear(year) ? yearOnly[1] : null;
}
return null;
}
function validDate(year: number, month: number, day: number): boolean {
if (!validYear(year) || !validMonth(month) || day < 1 || day > 31) return false;
const date = new Date(Date.UTC(year, month - 1, day));
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
}
function validYear(year: number): boolean {
return Number.isInteger(year) && year >= minimumYear && year <= maximumYear;
}
function validMonth(month: number): boolean {
return Number.isInteger(month) && month >= 1 && month <= 12;
}

View File

@ -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);
}
}

View File

@ -0,0 +1,127 @@
import { MetadataMatch, MetadataSearchQuery } from "../metadata.types.js";
export type ScoredMetadataMatch = {
match: MetadataMatch;
score: number;
titleScore: number;
authorScore: number | null;
dateScore: number | null;
isbnMatch: boolean;
};
export class ScoreMetadataMatch {
score(query: MetadataSearchQuery, match: MetadataMatch): number {
return this.details(query, match).score;
}
details(query: MetadataSearchQuery, match: MetadataMatch): ScoredMetadataMatch {
const titleScore = scoreTitle(query.title, match.scoreTitle ?? match.title ?? "");
const authorScore = query.author ? scoreAuthor(query.author, match.author) : null;
const dateScore = query.year ? scoreDate(query.year, match.publishedDate) : null;
const isbnMatch = exactIsbnMatch(query.isbn, match.isbn);
const maxPossible = 100 + (authorScore == null ? 0 : 30) + (dateScore == null ? 0 : 10);
const sum = titleScore + (authorScore ?? 0) + (dateScore ?? 0);
return {
match,
score: maxPossible ? Math.round((100 * sum) / maxPossible) : 0,
titleScore,
authorScore,
dateScore,
isbnMatch
};
}
best(query: MetadataSearchQuery, matches: MetadataMatch[], minimumScore = 0): ScoredMetadataMatch | null {
const scored = matches
.map((match) => this.details(query, match))
.sort((left, right) => compareScoredMatches(query, left, right));
const best = scored[0];
return best && best.score >= minimumScore ? best : null;
}
}
function compareScoredMatches(query: MetadataSearchQuery, left: ScoredMetadataMatch, right: ScoredMetadataMatch): number {
if (left.isbnMatch !== right.isbnMatch) return left.isbnMatch ? -1 : 1;
const leftTitleAuthor = isHighConfidenceTitleAuthor(query, left);
const rightTitleAuthor = isHighConfidenceTitleAuthor(query, right);
if (leftTitleAuthor !== rightTitleAuthor) return leftTitleAuthor ? -1 : 1;
return right.score - left.score;
}
function isHighConfidenceTitleAuthor(query: MetadataSearchQuery, scored: ScoredMetadataMatch): boolean {
return scored.titleScore > 90 && (!query.author || (scored.authorScore ?? 0) >= 15);
}
function scoreTitle(left: string, right: string): number {
const normalizedLeft = normalizeTitle(left);
const normalizedRight = normalizeTitle(right);
if (!normalizedLeft || !normalizedRight) return 0;
if (normalizedLeft === normalizedRight) return 100;
if (normalizedLeft.includes(normalizedRight) || normalizedRight.includes(normalizedLeft)) return 95;
return Math.round(jaccard(tokens(normalizedLeft), tokens(normalizedRight)) * 100);
}
function scoreAuthor(localAuthor: string, sourceAuthor: string | null | undefined): number {
const local = authorSet(localAuthor);
if (!local.size) return 0;
const source = authorSet(sourceAuthor ?? "");
const present = [...local].filter((author) => source.has(author)).length;
return 30 * (present / local.size);
}
function scoreDate(localYear: string, sourceDate: string | null | undefined): number {
const left = Number(yearFrom(localYear));
const right = Number(yearFrom(sourceDate ?? ""));
if (!left || !right) return 0;
if (left === right) return 10;
return Math.abs(left - right) <= 1 ? 5 : 0;
}
function exactIsbnMatch(left: string | null | undefined, right: string | null | undefined): boolean {
const normalizedLeft = normalizeIsbn(left);
const normalizedRight = normalizeIsbn(right);
return Boolean(normalizedLeft && normalizedRight && normalizedLeft === normalizedRight);
}
function normalizeTitle(value: string): string {
return normalizeText(value.split(":")[0] ?? "").replace(/^(?:le|la|les|the|a|an|l)\s+/, "");
}
function normalizeText(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function authorSet(value: string): Set<string> {
return new Set(
value
.split(/[,;&/]|\band\b|\bet\b/gi)
.map(normalizeText)
.filter(Boolean)
.sort()
);
}
function tokens(value: string): Set<string> {
return new Set(value.split(" ").filter(Boolean));
}
function jaccard(leftTokens: Set<string>, rightTokens: Set<string>): number {
const intersection = [...leftTokens].filter((token) => rightTokens.has(token)).length;
const union = new Set([...leftTokens, ...rightTokens]).size;
return union ? intersection / union : 0;
}
function yearFrom(value: string): string | null {
return value.match(/\b(1[5-9]\d{2}|20\d{2})\b/)?.[1] ?? null;
}
function normalizeIsbn(value: string | null | undefined): string | null {
const normalized = value?.replace(/[^0-9X]/gi, "").toUpperCase() ?? "";
return normalized || null;
}

View File

@ -17,7 +17,7 @@ export class ProgressController {
@Get(":bookId")
get(@CurrentUserParam() user: CurrentUser, @Param("bookId") bookId: string) {
return this.progress.get(user.id, Number(bookId));
return this.progress.find(user.id, Number(bookId));
}
@Put(":bookId")

View File

@ -30,6 +30,14 @@ export class ProgressService {
}
get(userId: number, bookId: number) {
const row = this.find(userId, bookId);
if (!row) {
throw new NotFoundException("Progress not found");
}
return row;
}
find(userId: number, bookId: number) {
const row = this.database.db
.select({
bookId: progress.bookId,
@ -40,10 +48,7 @@ export class ProgressService {
.from(progress)
.where(sql`${progress.userId} = ${userId} AND ${progress.bookId} = ${bookId}`)
.get();
if (!row) {
throw new NotFoundException("Progress not found");
}
return row;
return row ?? null;
}
continueReading(userId: number) {

View File

@ -0,0 +1,26 @@
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
import { UpdateReaderPreferencesDto, UpdateReaderPreferencesSchema } from "@readabook/shared";
import { AuthGuard } from "../auth/auth.guard.js";
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { ReaderPreferencesService } from "./reader-preferences.service.js";
@Controller("reader/preferences")
@UseGuards(AuthGuard)
export class ReaderPreferencesController {
constructor(private readonly preferences: ReaderPreferencesService) {}
@Get(":bookId")
get(@CurrentUserParam() user: CurrentUser, @Param("bookId") bookId: string) {
return this.preferences.find(user.id, Number(bookId));
}
@Put(":bookId")
update(
@CurrentUserParam() user: CurrentUser,
@Param("bookId") bookId: string,
@Body(new ZodValidationPipe(UpdateReaderPreferencesSchema)) body: UpdateReaderPreferencesDto
) {
return this.preferences.upsert(user.id, Number(bookId), body);
}
}

View File

@ -0,0 +1,47 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { eq, sql } from "drizzle-orm";
import { UpdateReaderPreferencesDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { books, readerPreferences } from "../database/schema.js";
@Injectable()
export class ReaderPreferencesService {
constructor(private readonly database: DatabaseService) {}
find(userId: number, bookId: number) {
const row = this.database.db
.select({
mode: readerPreferences.mode,
fit: readerPreferences.fit,
updatedAt: readerPreferences.updatedAt
})
.from(readerPreferences)
.where(sql`${readerPreferences.userId} = ${userId} AND ${readerPreferences.bookId} = ${bookId}`)
.get();
return row ?? { mode: "paged", fit: null };
}
upsert(userId: number, bookId: number, input: UpdateReaderPreferencesDto) {
const book = this.database.db.select({ id: books.id }).from(books).where(eq(books.id, bookId)).get();
if (!book) throw new NotFoundException("Book not found");
const current = this.find(userId, bookId);
const now = this.database.now();
const mode = input.mode ?? current.mode;
const fit = input.fit === undefined ? current.fit : input.fit;
this.database.sqlite
.prepare(
`
INSERT INTO reader_preferences(user_id, book_id, mode, fit, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, book_id) DO UPDATE SET
mode = excluded.mode,
fit = excluded.fit,
updated_at = excluded.updated_at
`
)
.run(userId, bookId, mode, fit, now, now);
return this.find(userId, bookId);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js";
import { ReaderPreferencesController } from "./reader-preferences.controller.js";
import { ReaderPreferencesService } from "./reader-preferences.service.js";
@Module({
imports: [AuthModule, DatabaseModule],
controllers: [ReaderPreferencesController],
providers: [ReaderPreferencesService]
})
export class ReaderModule {}

View File

@ -1,18 +1,56 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import AdmZip from "adm-zip";
import { describe, expect, it, vi } from "vitest";
import { listCbzImageEntries } from "../common/cbz.js";
import { extractMetadata } from "./metadata.js";
vi.mock("../common/cbr.js", () => ({
listCbrImageEntries: async () => [{ entryName: "001.jpg", name: "001.jpg" }],
readCbrPage: async () => ({ entryName: "001.jpg", data: Buffer.from([0xff, 0xd8, 0xff, 0xd9]) })
}));
describe("pdf metadata extraction", () => {
it("falls back to file name and reads simple PDF info fields", () => {
it("falls back to file name and reads simple PDF info fields", async () => {
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
const file = join(dir, "Example.pdf");
writeFileSync(file, "%PDF-1.4\n1 0 obj << /Title (My Book) /Author (Ada) >> endobj");
const metadata = extractMetadata(file, dir);
const metadata = await extractMetadata(file, dir);
expect(metadata.title).toBe("My Book");
expect(metadata.author).toBe("Ada");
});
});
describe("cbz metadata extraction", () => {
it("uses the file name as title and first image as cover", async () => {
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
const file = join(dir, "Comic One.cbz");
const zip = new AdmZip();
zip.addFile("002.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
zip.writeZip(file);
const metadata = await extractMetadata(file, dir);
const pages = listCbzImageEntries(file);
expect(metadata.title).toBe("Comic One");
expect(metadata.coverPath).toMatch(/covers\/[a-f0-9]+\.jpg$/);
expect(pages.map((page) => page.name)).toEqual(["001.jpg", "002.jpg"]);
});
});
describe("cbr metadata extraction", () => {
it("uses the file name as title and first extracted image as cover", async () => {
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
const file = join(dir, "Comic Two.cbr");
writeFileSync(file, "rar");
const metadata = await extractMetadata(file, dir);
expect(metadata.title).toBe("Comic Two");
expect(metadata.coverPath).toMatch(/covers\/[a-f0-9]+\.jpg$/);
});
});

View File

@ -3,6 +3,9 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, extname, join } from "node:path";
import AdmZip from "adm-zip";
import { XMLParser } from "fast-xml-parser";
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
import { listCbzImageEntries } from "../common/cbz.js";
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
export type BookMetadata = {
title: string;
@ -21,11 +24,17 @@ const xmlParser = new XMLParser({
textNodeName: "#text"
});
export function extractMetadata(filePath: string, storageDir: string): BookMetadata {
export async function extractMetadata(filePath: string, storageDir: string): Promise<BookMetadata> {
const extension = extname(filePath).toLowerCase();
if (extension === ".epub") {
return extractEpubMetadata(filePath, storageDir);
}
if (extension === ".cbz") {
return extractCbzMetadata(filePath, storageDir);
}
if (extension === ".cbr") {
return extractCbrMetadata(filePath, storageDir);
}
return extractPdfMetadata(filePath);
}
@ -56,7 +65,7 @@ function extractEpubMetadata(filePath: string, storageDir: string): BookMetadata
isbn,
language: firstText(metadata["dc:language"]),
publisher: firstText(metadata["dc:publisher"]),
publishedDate: firstText(metadata["dc:date"]),
publishedDate: normalizePublishedDate(firstText(metadata["dc:date"])),
coverPath
};
}
@ -78,6 +87,26 @@ function extractPdfMetadata(filePath: string): BookMetadata {
};
}
function extractCbzMetadata(filePath: string, storageDir: string): BookMetadata {
const zip = new AdmZip(filePath);
const firstPage = listCbzImageEntries(filePath)[0];
const coverPath = extractCover(zip, firstPage.entryName, filePath, storageDir);
return {
...fallbackMetadata(filePath),
coverPath
};
}
async function extractCbrMetadata(filePath: string, storageDir: string): Promise<BookMetadata> {
const firstPage = (await listCbrImageEntries(filePath))[0];
const page = await readCbrPage(filePath, 1, storageDir);
const coverPath = writeCoverData(page.data, firstPage.entryName, filePath, storageDir);
return {
...fallbackMetadata(filePath),
coverPath
};
}
function fallbackMetadata(filePath: string): BookMetadata {
return {
title: basename(filePath, extname(filePath)),
@ -139,6 +168,15 @@ function extractCover(zip: AdmZip, coverPathInZip: string, filePath: string, sto
return target;
}
function writeCoverData(data: Buffer, entryName: string, filePath: string, storageDir: string): string {
const extension = extname(entryName) || ".jpg";
const hash = createHash("sha256").update(filePath).digest("hex").slice(0, 24);
const target = join(storageDir, "covers", `${hash}${extension}`);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, data);
return target;
}
function matchPdfInfo(text: string, key: string): string | null {
return text.match(new RegExp(`/${key}\\s*\\(([^)]{1,500})\\)`))?.[1] ?? null;
}

View File

@ -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 {}

View File

@ -0,0 +1,252 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DatabaseService } from "../database/database.service.js";
import { books, libraries } from "../database/schema.js";
import { JobsService } from "../jobs/jobs.service.js";
import { enrichmentDigest, preserveExistingBookValues, scanDigest } from "./scanner.service.js";
import { ScannerService } from "./scanner.service.js";
const previousDatabasePath = process.env.DATABASE_PATH;
const previousStorageDir = process.env.STORAGE_DIR;
const tempDirs: string[] = [];
afterEach(() => {
process.env.DATABASE_PATH = previousDatabasePath;
process.env.STORAGE_DIR = previousStorageDir;
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("scan digest", () => {
it("reports incomplete files without exposing huge traces", () => {
const detail = scanDigest(
19,
0,
[
{ filePath: "/library/broken.cbr", error: "x".repeat(300) },
{ filePath: "/library/broken.epub", error: "Invalid EPUB" }
]
);
expect(detail).toContain("Scanned 19 file(s)");
expect(detail).toContain("2 incomplete file(s)");
expect(detail).toContain("broken.cbr");
expect(detail.length).toBeLessThan(380);
});
it("reports metadata enrichment jobs as enrichment, not scans", () => {
expect(enrichmentDigest(35, [])).toBe("Enriched 35 book(s)");
});
it("does not erase existing metadata or cover when a rescan has less information", () => {
const existing: typeof books.$inferSelect = {
id: 1,
libraryId: 1,
seriesId: null,
title: "Daredevil",
author: "Roy Thomas",
description: "Existing description",
isbn: "9782809476255",
isbn13: "9782809476255",
identifiersJson: null,
localMetadataJson: null,
language: "fre",
publisher: "Panini comics",
publishedDate: "2019",
volumeNumber: null,
volumeLabel: null,
format: "cbz",
filePath: "/library/Daredevil.cbz",
coverPath: "/storage/covers/daredevil.jpg",
metadataStatus: "enriched",
metadataProvenanceJson: JSON.stringify({ author: "bnf", coverPath: "openlibrary" }),
scanStatus: "succeeded",
enrichmentStatus: "succeeded",
fileSize: 12,
fileMtime: "2026-08-23T00:00:00.000Z",
createdAt: "2026-08-23T00:00:00.000Z",
updatedAt: "2026-08-23T00:00:00.000Z"
};
const next = preserveExistingBookValues(
{
title: "Daredevil",
author: null,
description: null,
isbn: null,
isbn13: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: JSON.stringify({ title: "local" }),
scanStatus: "succeeded" as const
},
existing
);
expect(next).toMatchObject({
author: "Roy Thomas",
description: "Existing description",
isbn: "9782809476255",
coverPath: "/storage/covers/daredevil.jpg",
metadataStatus: "enriched"
});
expect(JSON.parse(String(next.metadataProvenanceJson))).toMatchObject({
title: "local",
author: "bnf",
coverPath: "openlibrary"
});
});
it("does not replace an existing valid publication date with a sentinel date", () => {
const existing = {
id: 1,
libraryId: 1,
seriesId: null,
title: "Lord of the Mysteries",
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: null,
localMetadataJson: null,
language: null,
publisher: null,
publishedDate: "2018",
volumeNumber: null,
volumeLabel: null,
format: "epub",
filePath: "/library/Lord of the Mysteries.epub",
coverPath: null,
metadataStatus: "partial",
metadataProvenanceJson: JSON.stringify({ publishedDate: "existing" }),
scanStatus: "succeeded",
enrichmentStatus: "succeeded",
fileSize: 12,
fileMtime: "2026-08-23T00:00:00.000Z",
createdAt: "2026-08-23T00:00:00.000Z",
updatedAt: "2026-08-23T00:00:00.000Z"
} satisfies typeof books.$inferSelect;
const next = preserveExistingBookValues(
{
title: "Lord of the Mysteries",
publishedDate: "0101-01-01T00:00:00+00:00",
metadataStatus: "partial",
metadataProvenanceJson: JSON.stringify({ title: "local", publishedDate: "openlibrary" })
},
existing
);
expect(next.publishedDate).toBe("2018");
});
it.runIf(canLoadBetterSqlite())("updates the existing book when an insert races with books.file_path uniqueness", () => {
const database = createDatabase();
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
database.db
.insert(books)
.values({
libraryId: library.id,
seriesId: null,
title: "Daredevil",
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: null,
localMetadataJson: null,
language: null,
publisher: null,
publishedDate: null,
volumeNumber: null,
volumeLabel: null,
format: "cbz",
filePath: "/library/Daredevil.cbz",
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: null,
scanStatus: "succeeded",
enrichmentStatus: "succeeded",
fileSize: 1,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.run();
const scanner = new ScannerService(database, new JobsService(database), {} as never);
const values: Omit<typeof books.$inferInsert, "createdAt"> = {
libraryId: library.id,
seriesId: null,
title: "Daredevil",
author: "Roy Thomas",
description: "Updated metadata",
isbn: null,
isbn13: null,
identifiersJson: null,
localMetadataJson: null,
language: null,
publisher: null,
publishedDate: null,
volumeNumber: null,
volumeLabel: null,
format: "cbz",
filePath: "/library/Daredevil.cbz",
coverPath: "/storage/covers/daredevil.jpg",
metadataStatus: "enriched",
metadataProvenanceJson: JSON.stringify({ author: "bnf", coverPath: "local" }),
scanStatus: "succeeded",
enrichmentStatus: "succeeded",
fileSize: 2,
fileMtime: now,
updatedAt: now
};
(scanner as unknown as {
upsertBookByFilePath(values: Omit<typeof books.$inferInsert, "createdAt">, existing: undefined, createdAt: string): void;
}).upsertBookByFilePath(
values,
undefined,
now
);
const rows = database.db.select().from(books).all();
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
author: "Roy Thomas",
coverPath: "/storage/covers/daredevil.jpg",
metadataStatus: "enriched",
fileSize: 2
});
database.onModuleDestroy();
});
});
function createDatabase(): DatabaseService {
const dir = mkdtempSync(join(tmpdir(), "readabook-scanner-service-"));
tempDirs.push(dir);
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
process.env.STORAGE_DIR = join(dir, "storage");
return new DatabaseService();
}
function canLoadBetterSqlite(): boolean {
try {
const database = createDatabase();
database.onModuleDestroy();
return true;
} catch {
return false;
}
}

View File

@ -1,19 +1,21 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { readdirSync, statSync } from "node:fs";
import { extname, join } from "node:path";
import { eq } from "drizzle-orm";
import { existsSync, readdirSync, statSync } from "node:fs";
import { basename, extname, join } from "node:path";
import { eq, inArray } from "drizzle-orm";
import { DatabaseService } from "../database/database.service.js";
import { books, libraries } from "../database/schema.js";
import { automationSettings, books, libraries, series } from "../database/schema.js";
import { JobsService } from "../jobs/jobs.service.js";
import { MetadataService } from "../metadata/metadata.service.js";
import { extractSeriesVolume } from "../metadata/use-cases/extract-series-volume.js";
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
import { extractMetadata } from "./metadata.js";
import { OpenLibraryService } from "./open-library.service.js";
@Injectable()
export class ScannerService {
constructor(
private readonly database: DatabaseService,
private readonly jobs: JobsService,
private readonly openLibrary: OpenLibraryService
private readonly metadata: MetadataService
) {}
enqueueLibraryScan(libraryId: number) {
@ -28,51 +30,271 @@ export class ScannerService {
return job;
}
enqueueAllLibrariesScan(detail = "Scanning all enabled libraries") {
const job = this.jobs.create("library-scan-all", detail);
setImmediate(() => {
void this.scanAllLibraries(job.id).catch((error) => this.jobs.markFailed(job.id, error));
});
return job;
}
enqueueMetadataEnrichment(detail = "Enriching existing books") {
const job = this.jobs.create("metadata-enrich", detail);
setImmediate(() => {
void this.enrichExistingBooks(job.id).catch((error) => this.jobs.markFailed(job.id, error));
});
return job;
}
private async scanLibrary(jobId: number, library: typeof libraries.$inferSelect): Promise<void> {
this.jobs.markRunning(jobId, `Scanning ${library.path}`);
let count = 0;
const failures: ScanFailure[] = [];
const seen = new Set<string>();
for (const filePath of walkBooks(library.path)) {
await this.ingestFile(library.id, filePath);
count += 1;
seen.add(filePath);
try {
await this.ingestFile(library.id, filePath);
count += 1;
} catch (error) {
failures.push({ filePath, error: errorMessage(error) });
this.ingestIncompleteFile(library.id, filePath);
}
}
this.jobs.markSucceeded(jobId, `Scanned ${count} file(s)`);
const removed = this.removeMissingBooks(library.id, seen);
this.jobs.markSucceeded(jobId, scanDigest(count, removed, failures));
}
private async scanAllLibraries(jobId: number): Promise<void> {
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<void> {
this.jobs.markRunning(jobId, "Enriching existing books");
const rows = this.database.db.select({ id: books.id }).from(books).all();
let count = 0;
const failures: ScanFailure[] = [];
for (const row of rows) {
this.markBookEnrichmentStatus(row.id, "running");
try {
await this.metadata.enrichBook(row.id);
this.markBookEnrichmentStatus(row.id, "succeeded");
count += 1;
} catch (error) {
this.markBookEnrichmentStatus(row.id, "failed");
failures.push({ filePath: `book #${row.id}`, error: errorMessage(error) });
}
}
this.jobs.markSucceeded(jobId, enrichmentDigest(count, failures));
}
private async ingestFile(libraryId: number, filePath: string): Promise<void> {
const stats = statSync(filePath);
let metadata = extractMetadata(filePath, this.database.config.storageDir);
if (this.database.config.openLibraryEnabled) {
try {
metadata = { ...metadata, ...(await this.openLibrary.enrich(metadata)) };
} catch {
// Remote enrichment is opportunistic; local ingestion must stay deterministic.
}
const existing = this.database.db.select().from(books).where(eq(books.filePath, filePath)).get();
if (existing) {
this.database.db
.update(books)
.set({ scanStatus: "running", enrichmentStatus: "running", updatedAt: this.database.now() })
.where(eq(books.id, existing.id))
.run();
}
const localMetadata = await extractMetadata(filePath, this.database.config.storageDir);
const now = this.database.now();
const format: "epub" | "pdf" = extname(filePath).toLowerCase() === ".epub" ? "epub" : "pdf";
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get();
const format = bookFormatFromPath(filePath);
const shouldRemoteEnrich = !existing ? this.shouldAutoEnrichNewBooks() : true;
const metadata = await this.metadata.enrichMetadata(localMetadata, filePath, { remote: shouldRemoteEnrich });
const seriesInfo = this.resolveSeries(metadata.title, filePath);
const values = {
libraryId,
seriesId: seriesInfo.seriesId,
title: metadata.title,
author: metadata.author,
description: metadata.description,
isbn: metadata.isbn,
isbn13: metadata.isbn13,
identifiersJson: metadata.identifiersJson,
localMetadataJson: metadata.localMetadataJson,
language: metadata.language,
publisher: metadata.publisher,
publishedDate: metadata.publishedDate,
volumeNumber: seriesInfo.volumeNumber,
volumeLabel: seriesInfo.volumeLabel,
format,
filePath,
coverPath: metadata.coverPath,
metadataStatus: metadata.metadataStatus,
metadataProvenanceJson: metadata.metadataProvenanceJson,
scanStatus: "succeeded" as const,
enrichmentStatus: shouldRemoteEnrich ? ("succeeded" as const) : ("idle" as const),
fileSize: stats.size,
fileMtime: stats.mtime.toISOString(),
updatedAt: now
};
existing
? this.database.db.update(books).set(values).where(eq(books.id, existing.id)).returning().get()
: this.database.db.insert(books).values({ ...values, createdAt: now }).returning().get();
this.upsertBookByFilePath(values, existing, now);
}
private ingestIncompleteFile(libraryId: number, filePath: string): void {
const stats = statSync(filePath);
const now = this.database.now();
const existing = this.database.db.select().from(books).where(eq(books.filePath, filePath)).get();
const seriesInfo = this.resolveSeries(basename(filePath, extname(filePath)), filePath);
const values = {
libraryId,
seriesId: seriesInfo.seriesId,
title: basename(filePath, extname(filePath)),
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: JSON.stringify({ candidates: [], isbn10: null, isbn13: null }),
localMetadataJson: JSON.stringify({
title: basename(filePath, extname(filePath)),
author: null,
year: null,
isbn: null,
fileTitle: basename(filePath, extname(filePath)),
raw: {
title: basename(filePath, extname(filePath)),
author: null,
publishedDate: null,
fileName: basename(filePath, extname(filePath))
}
}),
language: null,
publisher: null,
publishedDate: null,
volumeNumber: seriesInfo.volumeNumber,
volumeLabel: seriesInfo.volumeLabel,
format: bookFormatFromPath(filePath),
filePath,
coverPath: null,
metadataStatus: "none" as const,
metadataProvenanceJson: JSON.stringify({ title: "local" }),
scanStatus: "failed" as const,
enrichmentStatus: "failed" as const,
fileSize: stats.size,
fileMtime: stats.mtime.toISOString(),
updatedAt: now
};
this.upsertBookByFilePath(values, existing, now);
}
private upsertBookByFilePath(
values: Omit<typeof books.$inferInsert, "createdAt">,
existing: typeof books.$inferSelect | undefined,
createdAt: string
): void {
if (existing) {
this.database.db.update(books).set(preserveExistingBookValues(values, existing)).where(eq(books.id, existing.id)).run();
return;
}
try {
this.database.db.insert(books).values({ ...values, createdAt }).run();
return;
} catch (error) {
if (!isUniqueFilePathError(error)) throw error;
const current = this.database.db.select().from(books).where(eq(books.filePath, values.filePath)).get();
if (!current) throw error;
this.database.db.update(books).set(preserveExistingBookValues(values, current)).where(eq(books.id, current.id)).run();
}
}
private removeMissingBooks(libraryId: number, seen: Set<string>): number {
const existing = this.database.db.select({ id: books.id, filePath: books.filePath }).from(books).where(eq(books.libraryId, libraryId)).all();
const missing = existing.filter((book) => !seen.has(book.filePath) && !existsSync(book.filePath));
if (!missing.length) return 0;
this.database.db.delete(books).where(inArray(books.id, missing.map((book) => book.id))).run();
return missing.length;
}
private shouldAutoEnrichNewBooks(): boolean {
return Boolean(
this.database.db
.select({ autoEnrichNewBooks: automationSettings.autoEnrichNewBooks })
.from(automationSettings)
.where(eq(automationSettings.id, 1))
.get()?.autoEnrichNewBooks
);
}
private markBookEnrichmentStatus(id: number, enrichmentStatus: "running" | "succeeded" | "failed"): void {
this.database.db.update(books).set({ enrichmentStatus, updatedAt: this.database.now() }).where(eq(books.id, id)).run();
}
private resolveSeries(title: string, filePath: string): { seriesId: number; volumeNumber: number | null; volumeLabel: string | null } {
const parsed = extractSeriesVolume(title, filePath);
const now = this.database.now();
const row = this.database.db
.insert(series)
.values({
title: parsed.seriesTitle,
normalizedTitle: parsed.normalizedSeriesTitle,
description: null,
publisher: null,
createdAt: now,
updatedAt: now
})
.onConflictDoUpdate({
target: series.normalizedTitle,
set: { title: parsed.seriesTitle, updatedAt: now }
})
.returning({ id: series.id })
.get();
return { seriesId: row.id, volumeNumber: parsed.volumeNumber, volumeLabel: parsed.volumeLabel };
}
}
type ScanFailure = {
filePath: string;
error: string;
};
export function scanDigest(scanned: number, removed: number, failures: ScanFailure[], suffix?: string): string {
const base = suffix ? `Scanned ${scanned} file(s) ${suffix}` : `Scanned ${scanned} file(s), removed ${removed} missing book(s)`;
if (!failures.length) return base;
const examples = failures
.slice(0, 3)
.map((failure) => `${basename(failure.filePath)}: ${truncate(failure.error)}`)
.join("; ");
const extra = failures.length > 3 ? `; ${failures.length - 3} more` : "";
return `${base}, ${failures.length} incomplete file(s): ${examples}${extra}`;
}
export function enrichmentDigest(enriched: number, failures: ScanFailure[]): string {
const base = `Enriched ${enriched} book(s)`;
if (!failures.length) return base;
const examples = failures
.slice(0, 3)
.map((failure) => `${basename(failure.filePath)}: ${truncate(failure.error)}`)
.join("; ");
const extra = failures.length > 3 ? `; ${failures.length - 3} more` : "";
return `${base}, ${failures.length} incomplete book(s): ${examples}${extra}`;
}
function errorMessage(error: unknown): string {
if (error instanceof Error && error.message) return truncate(error.message);
return truncate(String(error));
}
function truncate(value: string): string {
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
}
function* walkBooks(root: string): Generator<string> {
@ -84,8 +306,62 @@ function* walkBooks(root: string): Generator<string> {
}
if (!entry.isFile()) continue;
const extension = extname(entry.name).toLowerCase();
if (extension === ".epub" || extension === ".pdf") {
if (extension === ".epub" || extension === ".pdf" || extension === ".cbz" || extension === ".cbr") {
yield path;
}
}
}
function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" | "cbr" {
const extension = extname(filePath).toLowerCase();
if (extension === ".epub") return "epub";
if (extension === ".cbz") return "cbz";
if (extension === ".cbr") return "cbr";
return "pdf";
}
export function preserveExistingBookValues<T extends Partial<typeof books.$inferInsert>>(values: T, existing: typeof books.$inferSelect): T {
const next = { ...values };
if (next.scanStatus === "failed" && existing.title) {
next.title = existing.title as never;
}
for (const field of ["author", "description", "isbn", "isbn13", "language", "publisher", "publishedDate", "coverPath", "seriesId", "volumeNumber", "volumeLabel"] as const) {
if (field === "publishedDate") {
next.publishedDate = (normalizePublishedDate(next.publishedDate) ?? normalizePublishedDate(existing.publishedDate)) as never;
continue;
}
if (next[field] == null && existing[field] != null) {
next[field] = existing[field] as never;
}
}
next.metadataStatus = computeMetadataStatus(next, existing.metadataStatus) as never;
next.metadataProvenanceJson = mergeProvenanceJson(String(next.metadataProvenanceJson ?? "{}"), existing.metadataProvenanceJson) as never;
return next;
}
function computeMetadataStatus(values: Partial<typeof books.$inferInsert>, existingStatus: string): "enriched" | "partial" | "none" {
const hasCover = Boolean(values.coverPath);
const filled = [values.author, values.description, values.isbn, values.language, values.publisher, values.publishedDate].filter(Boolean).length;
const computed = hasCover && filled >= 2 ? "enriched" : hasCover || filled > 0 ? "partial" : "none";
const rank = { none: 0, partial: 1, enriched: 2 } as const;
const safeExisting = existingStatus === "enriched" || existingStatus === "partial" || existingStatus === "none" ? existingStatus : "none";
return rank[computed] >= rank[safeExisting] ? computed : safeExisting;
}
function mergeProvenanceJson(nextJson: string, existingJson: string | null): string {
return JSON.stringify({ ...parseJsonObject(existingJson), ...parseJsonObject(nextJson) });
}
function parseJsonObject(value: string | null): Record<string, unknown> {
if (!value) return {};
try {
const parsed = JSON.parse(value) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
function isUniqueFilePathError(error: unknown): boolean {
return error instanceof Error && error.message.includes("UNIQUE constraint failed: books.file_path");
}

View File

@ -4,26 +4,50 @@ server {
root /usr/share/nginx/html;
index index.html;
location /auth/ {
proxy_pass http://api:3000/auth/;
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files /sw.js =404;
}
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files /index.html =404;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location ^~ /auth {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /admin/ {
proxy_pass http://api:3000/admin/;
location ^~ /admin {
if ($http_accept ~* "text/html") {
rewrite ^ /index.html last;
}
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /books/ {
proxy_pass http://api:3000/books/;
location ^~ /books {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /progress/ {
proxy_pass http://api:3000/progress/;
location ^~ /series {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location ^~ /progress {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}

View File

@ -22,6 +22,7 @@
"vite": "^8.2.2"
},
"devDependencies": {
"@napi-rs/canvas": "1.0.7",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.3",
"typescript": "^5.7.3",

View File

@ -1,5 +1,5 @@
const CACHE_NAME = "readabook-shell-v1";
const SHELL = ["/", "/home", "/manifest.webmanifest", "/icons/readabook.svg"];
const CACHE_NAME = "readabook-shell-v2";
const SHELL = ["/", "/index.html", "/manifest.webmanifest", "/icons/readabook.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
@ -18,5 +18,19 @@ self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET" || ["/auth", "/admin", "/books", "/progress"].some((path) => url.pathname.startsWith(path))) {
return;
}
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((hit) => hit || caches.match("/"))));
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put("/index.html", copy));
return response;
})
.catch(() => caches.match("/index.html").then((hit) => hit || caches.match("/")))
);
return;
}
event.respondWith(
caches.match(event.request).then((hit) => hit || fetch(event.request))
);
});

View File

@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import type { Session } from "./api/types";
import { api } from "./api/client";
import { isPrivateRoute } from "./auth/routing";
import { AppShell } from "./layout/AppShell";
import { AdminAutomationPage } from "./pages/AdminAutomationPage";
import { AdminPage } from "./pages/AdminPage";
import { BookPage } from "./pages/BookPage";
import { HomePage } from "./pages/HomePage";
@ -10,8 +12,9 @@ import { LoginPage } from "./pages/LoginPage";
import { ProfilePage } from "./pages/ProfilePage";
import { ReaderPage } from "./pages/ReaderPage";
import { SearchPage } from "./pages/SearchPage";
import { SeriesPage } from "./pages/SeriesPage";
import { SetupPage } from "./pages/SetupPage";
import { parseRoute, type Route } from "./router";
import { navigate, parseRoute, type Route } from "./router";
function renderRoute(route: Route, session: Session, refreshSession: () => Promise<void>) {
if (route.name === "login") return <LoginPage onSessionChange={refreshSession} />;
@ -22,6 +25,8 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
<HomePage />
) : route.name === "library" ? (
<LibraryPage libraryId={route.libraryId} />
) : route.name === "catalogSeries" ? (
<SeriesPage seriesName={route.seriesName} />
) : route.name === "book" ? (
<BookPage bookId={route.bookId} />
) : route.name === "reader" ? (
@ -30,19 +35,27 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
<SearchPage />
) : route.name === "me" ? (
<ProfilePage session={session} onSessionChange={refreshSession} />
) : route.name === "admin" && route.section === "automation" ? (
<AdminAutomationPage />
) : (
<AdminPage />
);
return <AppShell session={session}>{content}</AppShell>;
return (
<AppShell session={session} readerLayout={route.name === "reader"}>
{content}
</AppShell>
);
}
export function App() {
const [route, setRoute] = useState(parseRoute());
const [session, setSession] = useState<Session>({ user: null, degraded: false });
const [sessionChecked, setSessionChecked] = useState(false);
async function refreshSession() {
setSession(await api.session());
setSessionChecked(true);
}
useEffect(() => {
@ -55,5 +68,35 @@ export function App() {
return () => window.removeEventListener("popstate", listener);
}, []);
useEffect(() => {
const listener = () => {
setSession({ user: null, degraded: false });
setSessionChecked(true);
if (isPrivateRoute(parseRoute())) navigate("/login");
};
window.addEventListener("readabook:session-expired", listener);
return () => window.removeEventListener("readabook:session-expired", listener);
}, []);
useEffect(() => {
if (sessionChecked && !session.user && isPrivateRoute(route)) navigate("/login");
}, [route, session.user, sessionChecked]);
if (!sessionChecked && isPrivateRoute(route)) {
return (
<div className="auth-surface">
<section className="auth-hero">
<p>Cabinet de curiosites numerique</p>
<h1>ReadaBook</h1>
<span>Verification de session.</span>
</section>
</div>
);
}
if (sessionChecked && !session.user && isPrivateRoute(route)) {
return <LoginPage onSessionChange={refreshSession} />;
}
return renderRoute(route, session, refreshSession);
}

View File

@ -0,0 +1,151 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { api, ApiFallbackError, getApiFallback } from "./client";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("api fallback helpers", () => {
it("extracts typed fallback payloads", () => {
expect(getApiFallback<string[]>(new ApiFallbackError("offline", ["demo"]))).toEqual(["demo"]);
});
it("ignores non fallback errors", () => {
expect(getApiFallback<string[]>(new Error("boom"))).toBeUndefined();
});
it("does not cap the home catalogue request to the first 50 books", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" }
})
);
vi.stubGlobal("fetch", fetchMock);
await api.books();
expect(String(fetchMock.mock.calls[0][0])).not.toContain("limit=50");
});
it("does not cap search requests to the first 50 books", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" }
})
);
vi.stubGlobal("fetch", fetchMock);
await api.search("daredevil");
expect(String(fetchMock.mock.calls[0][0])).not.toContain("limit=50");
});
it("does not send JSON content-type for bodyless delete requests", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" }
})
);
vi.stubGlobal("fetch", fetchMock);
await api.deleteLibrary(42);
const init = fetchMock.mock.calls[0][1] as RequestInit;
const headers = new Headers(init.headers);
expect(init.method).toBe("DELETE");
expect(headers.has("Content-Type")).toBe(false);
});
it("surfaces create library API errors without fallback", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "Library path does not exist" }), {
status: 400,
statusText: "Bad Request",
headers: { "Content-Type": "application/json" }
})
);
vi.stubGlobal("fetch", fetchMock);
await expect(api.createLibrary({ name: "Books", path: "/missing", enabled: true })).rejects.toThrow("Library path does not exist");
});
it("does not fallback when scan enqueue fails", async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error("offline"));
vi.stubGlobal("fetch", fetchMock);
await expect(api.scanLibrary(42)).rejects.toThrow("offline");
});
it("keeps reader preferences locally when the backend contract is absent", async () => {
const storage = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
clear: () => storage.clear()
});
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 404, statusText: "Not Found" }));
vi.stubGlobal("fetch", fetchMock);
await expect(api.readerPreferences(8)).resolves.toEqual({ mode: "horizontal", fit: "page" });
await expect(api.saveReaderPreferences(8, { mode: "vertical", fit: "width" })).resolves.toEqual({ mode: "vertical", fit: "width" });
expect(storage.get("readabook:reader-preferences:8")).toBe(JSON.stringify({ mode: "vertical", fit: "width" }));
});
it("sends metadata source updates to the admin endpoint", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ isbnPriorityEnabled: false, sources: [] }), {
status: 200,
headers: { "Content-Type": "application/json" }
})
);
vi.stubGlobal("fetch", fetchMock);
await api.updateMetadataSources({
isbnPriorityEnabled: false,
sources: [{ provider: "openlibrary", enabled: true, priority: 1 }]
});
expect(fetchMock.mock.calls[0][0]).toBe("/admin/metadata-sources");
const init = fetchMock.mock.calls[0][1] as RequestInit;
expect(init.method).toBe("PUT");
expect(JSON.parse(init.body as string)).toEqual({
isbnPriorityEnabled: false,
sources: [{ provider: "openlibrary", enabled: true, priority: 1 }]
});
});
it("sends automation settings to the admin endpoint", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
watchLibraries: true,
autoEnrichNewBooks: true,
scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 },
enrichSchedule: { frequency: "disabled", time: "04:00", dayOfWeek: 1 }
}),
{
status: 200,
headers: { "Content-Type": "application/json" }
}
)
);
vi.stubGlobal("fetch", fetchMock);
await api.updateAutomationSettings({
watchLibraries: true,
scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 }
});
expect(fetchMock.mock.calls[0][0]).toBe("/admin/automation");
const init = fetchMock.mock.calls[0][1] as RequestInit;
expect(init.method).toBe("PUT");
expect(JSON.parse(init.body as string)).toEqual({
watchLibraries: true,
scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 }
});
});
});

View File

@ -1,19 +1,35 @@
import type {
BookDto,
BookQueryDto,
AuthStatusDto,
AutomationSettingsDto,
BootstrapAdminDto,
CreateLibraryDto,
JobDto,
LibraryDto,
LoginDto,
MetadataSourcesConfigDto,
ProgressDto,
UpdateAutomationSettingsDto,
UpdateAccountDto,
UpdateMetadataSourcesConfigDto,
UpdateProgressDto,
UserDto
} from "@readabook/shared";
import { mockBooks, mockContinue, mockJobs, mockLibraries, mockProgress, mockUser } from "./mockData";
import type { ContinueItem, Session } from "./types";
import {
mockAutomationSettings,
mockBooks,
mockContinue,
mockJobs,
mockLibraries,
mockMetadataSources,
mockProgress,
mockUser
} from "./mockData";
import type { CbzPagesDto, ContinueItem, ReaderPreferencesDto, Session } from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:";
type RequestOptions = RequestInit & {
fallback?: unknown;
@ -28,20 +44,54 @@ export class ApiFallbackError extends Error {
}
}
export class ApiHttpError extends Error {
constructor(
public readonly status: number,
message: string
) {
super(message);
}
}
export function getApiFallback<T>(error: unknown): T | undefined {
return error instanceof ApiFallbackError ? (error.fallback as T) : undefined;
}
function apiErrorMessage(detail: string, fallback: string): string {
if (!detail) return fallback;
try {
const parsed = JSON.parse(detail) as { message?: unknown; error?: unknown };
if (typeof parsed.message === "string") return parsed.message;
if (Array.isArray(parsed.message)) return parsed.message.join(", ");
if (typeof parsed.error === "string") return parsed.error;
} catch {
return detail;
}
return fallback;
}
function requestHeaders(options: RequestOptions): Headers {
const headers = new Headers(options.headers);
if (options.body !== undefined && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
return headers;
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
try {
const response = await fetch(`${API_BASE}${path}`, {
...options,
credentials: "include",
headers: {
"Content-Type": "application/json",
...options.headers
}
headers: requestHeaders(options)
});
if (!response.ok) {
if (response.status === 401 && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("readabook:session-expired"));
}
const detail = await response.text();
throw new Error(detail || `${response.status} ${response.statusText}`);
throw new ApiHttpError(response.status, apiErrorMessage(detail, `${response.status} ${response.statusText}`));
}
return (await response.json()) as T;
@ -62,6 +112,30 @@ function queryString(query: Partial<BookQueryDto>): string {
return value ? `?${value}` : "";
}
function readerPreferencesKey(bookId: number): string {
return `${READER_PREFERENCES_PREFIX}${bookId}`;
}
function readLocalReaderPreferences(bookId: number): ReaderPreferencesDto {
if (typeof localStorage === "undefined") return { mode: "horizontal", fit: "page" };
const raw = localStorage.getItem(readerPreferencesKey(bookId));
if (!raw) return { mode: "horizontal", fit: "page" };
try {
const parsed = JSON.parse(raw) as Partial<ReaderPreferencesDto>;
return {
mode: parsed.mode === "vertical" ? "vertical" : "horizontal",
fit: parsed.fit === "width" ? "width" : "page"
};
} catch {
return { mode: "horizontal", fit: "page" };
}
}
function writeLocalReaderPreferences(bookId: number, preferences: ReaderPreferencesDto): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(readerPreferencesKey(bookId), JSON.stringify(preferences));
}
export const api = {
async session(): Promise<Session> {
try {
@ -71,6 +145,9 @@ export const api = {
return { user: null, degraded: false };
}
},
async authStatus(): Promise<AuthStatusDto> {
return request<AuthStatusDto>("/auth/status");
},
async bootstrap(input: BootstrapAdminDto): Promise<UserDto> {
const result = await request<UserDto>("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) });
return result;
@ -82,11 +159,14 @@ export const api = {
async logout(): Promise<void> {
await request<{ ok: true }>("/auth/logout", { method: "POST" });
},
async updateMe(input: UpdateAccountDto): Promise<UserDto> {
return request<UserDto>("/auth/me", { method: "PATCH", body: JSON.stringify(input) });
},
async books(query: Partial<BookQueryDto> = {}): Promise<BookDto[]> {
return request<BookDto[]>(`/books${queryString({ limit: 50, offset: 0, ...query })}`, { fallback: mockBooks });
return request<BookDto[]>(`/books${queryString(query)}`, { fallback: mockBooks });
},
async search(query: string): Promise<BookDto[]> {
return request<BookDto[]>(`/books/search${queryString({ q: query, limit: 50, offset: 0 })}`, { fallback: mockBooks });
return request<BookDto[]>(`/books/search${queryString({ q: query })}`, { fallback: mockBooks });
},
async book(id: number): Promise<BookDto> {
const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0];
@ -98,6 +178,12 @@ export const api = {
bookCoverUrl(id: number): string {
return `${API_BASE}/books/${id}/cover`;
},
async cbzPages(id: number): Promise<CbzPagesDto> {
return request<CbzPagesDto>(`/books/${id}/pages`);
},
cbzPageUrl(id: number, page: number): string {
return `${API_BASE}/books/${id}/pages/${page}`;
},
async progress(bookId: number): Promise<ProgressDto | null> {
try {
return await request<ProgressDto>(`/progress/${bookId}`, {
@ -115,6 +201,31 @@ export const api = {
fallback: { bookId, ...input, updatedAt: new Date().toISOString() }
});
},
async readerPreferences(bookId: number): Promise<ReaderPreferencesDto> {
try {
const preferences = await request<ReaderPreferencesDto>(`/reader/preferences/${bookId}`, {
fallback: readLocalReaderPreferences(bookId)
});
writeLocalReaderPreferences(bookId, preferences);
return preferences;
} catch (error) {
if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto;
return readLocalReaderPreferences(bookId);
}
},
async saveReaderPreferences(bookId: number, input: ReaderPreferencesDto): Promise<ReaderPreferencesDto> {
writeLocalReaderPreferences(bookId, input);
try {
return await request<ReaderPreferencesDto>(`/reader/preferences/${bookId}`, {
method: "PUT",
body: JSON.stringify(input),
fallback: input
});
} catch (error) {
if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto;
return input;
}
},
async continueReading(): Promise<ContinueItem[]> {
return request<ContinueItem[]>("/progress/continue", { fallback: mockContinue });
},
@ -124,17 +235,45 @@ export const api = {
async createLibrary(input: CreateLibraryDto): Promise<LibraryDto> {
return request<LibraryDto>("/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<void> {
await request<{ ok: true }>(`/admin/libraries/${id}`, { method: "DELETE" });
},
async scanLibrary(id: number): Promise<JobDto> {
return request<JobDto>(`/admin/libraries/${id}/scan`, { method: "POST", fallback: mockJobs[0] });
return request<JobDto>(`/admin/libraries/${id}/scan`, { method: "POST" });
},
async jobs(): Promise<JobDto[]> {
return request<JobDto[]>("/admin/jobs", { fallback: mockJobs });
},
async users(): Promise<UserDto[]> {
return request<UserDto[]>("/admin/users", { fallback: [mockUser] });
},
async metadataSources(): Promise<MetadataSourcesConfigDto> {
return request<MetadataSourcesConfigDto>("/admin/metadata-sources", { fallback: mockMetadataSources });
},
async updateMetadataSources(input: UpdateMetadataSourcesConfigDto): Promise<MetadataSourcesConfigDto> {
return request<MetadataSourcesConfigDto>("/admin/metadata-sources", {
method: "PUT",
body: JSON.stringify(input),
fallback: mockMetadataSources
});
},
async automationSettings(): Promise<AutomationSettingsDto> {
return request<AutomationSettingsDto>("/admin/automation", { fallback: mockAutomationSettings });
},
async updateAutomationSettings(input: UpdateAutomationSettingsDto): Promise<AutomationSettingsDto> {
return request<AutomationSettingsDto>("/admin/automation", {
method: "PUT",
body: JSON.stringify(input),
fallback: mockAutomationSettings
});
},
async runAutomationScan(): Promise<JobDto> {
return request<JobDto>("/admin/automation/run-scan", { method: "POST", fallback: mockJobs[0] });
},
async runAutomationEnrich(): Promise<JobDto> {
return request<JobDto>("/admin/automation/run-enrich", { method: "POST", fallback: mockJobs[0] });
}
};

View File

@ -1,8 +1,27 @@
import type { BookDto, JobDto, LibraryDto, ProgressDto, UserDto } from "@readabook/shared";
import type { AutomationSettingsDto, BookDto, JobDto, LibraryDto, MetadataSourcesConfigDto, ProgressDto, UserDto } from "@readabook/shared";
import type { ContinueItem } from "./types";
const now = new Date().toISOString();
type BookPipelineStatus = "idle" | "running" | "succeeded" | "failed";
type BookMetadataStatus = "enriched" | "partial" | "none";
type MockBookDto = Omit<BookDto, "metadataStatus" | "metadataProvenance" | "scanStatus" | "enrichmentStatus"> & {
metadataStatus?: BookMetadataStatus;
metadataProvenance?: Record<string, string>;
scanStatus?: BookPipelineStatus;
enrichmentStatus?: BookPipelineStatus;
};
function mockBook(book: MockBookDto): BookDto {
return {
metadataStatus: "partial",
metadataProvenance: { local: "fixture" },
scanStatus: "idle",
enrichmentStatus: "idle",
...book
} as BookDto;
}
export const mockUser: UserDto = {
id: 1,
email: "admin@readabook.local",
@ -17,13 +36,14 @@ export const mockLibraries: LibraryDto[] = [
];
export const mockBooks: BookDto[] = [
{
mockBook({
id: 1,
libraryId: 1,
title: "L'Herbier des machines",
author: "M. Valrose",
description: "Fragments, croquis et notes rassemblees autour d'automates introuvables.",
isbn: null,
isbn13: null,
language: "fr",
publisher: "Cabinet ReadaBook",
publishedDate: "1908",
@ -34,14 +54,15 @@ export const mockBooks: BookDto[] = [
fileMtime: now,
createdAt: now,
updatedAt: now
},
{
}),
mockBook({
id: 2,
libraryId: 2,
title: "Cartographie des songes",
author: "I. Nadir",
description: "Un atlas annote ou chaque page devient une vitrine de lecture.",
isbn: null,
isbn13: null,
language: "fr",
publisher: "ReadaBook",
publishedDate: "1921",
@ -52,12 +73,52 @@ export const mockBooks: BookDto[] = [
fileMtime: now,
createdAt: now,
updatedAt: now
}
}),
mockBook({
id: 3,
libraryId: 2,
title: "Les vitrines de verre",
author: "A. Muze",
description: "Un recit graphique indexe comme archive CBZ.",
isbn: null,
isbn13: null,
language: "fr",
publisher: "ReadaBook",
publishedDate: "1934",
format: "cbz",
filePath: "/library/cbz/vitrines.cbz",
coverPath: null,
fileSize: 12600000,
fileMtime: now,
createdAt: now,
updatedAt: now
}),
mockBook({
id: 4,
libraryId: 2,
title: "Cabinet noir",
author: "L. Rar",
description: "Archive CBR lue avec le même parcours paginé que les comics CBZ.",
isbn: null,
isbn13: null,
language: "fr",
publisher: "ReadaBook",
publishedDate: "1937",
format: "cbr",
filePath: "/library/cbr/cabinet-noir.cbr",
coverPath: null,
fileSize: 14800000,
fileMtime: now,
createdAt: now,
updatedAt: now
})
];
export const mockProgress: ProgressDto[] = [
{ bookId: 1, locator: "mock:chapter-3", percent: 42, updatedAt: now },
{ bookId: 2, locator: "mock:page-12", percent: 18, updatedAt: now }
{ bookId: 2, locator: "pdf:page:12", percent: 18, updatedAt: now },
{ bookId: 3, locator: "cbz:page:4", percent: 40, updatedAt: now },
{ bookId: 4, locator: "cbr:page:6", percent: 60, updatedAt: now }
];
export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({
@ -68,3 +129,20 @@ export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({
export const mockJobs: JobDto[] = [
{ id: 1, type: "scan-library", status: "succeeded", detail: "2 ouvrages indexes", error: null, createdAt: now, updatedAt: now }
];
export const mockMetadataSources: MetadataSourcesConfigDto = {
isbnPriorityEnabled: true,
sources: [
{ provider: "local", enabled: true, priority: 0, hasApiKey: false },
{ provider: "openlibrary", enabled: true, priority: 1, hasApiKey: false },
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: false },
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false }
]
};
export const mockAutomationSettings: AutomationSettingsDto = {
watchLibraries: false,
autoEnrichNewBooks: true,
scanSchedule: { frequency: "disabled", time: "03:00", dayOfWeek: 1 },
enrichSchedule: { frequency: "weekly", time: "04:00", dayOfWeek: 1 }
};

View File

@ -21,3 +21,32 @@ export type DashboardData = {
libraries: LibraryDto[];
jobs: JobDto[];
};
export type CbzPagesDto = {
bookId: number;
pageCount: number;
pages: Array<{ page: number; name: string }>;
};
export type ReaderMode = "horizontal" | "vertical";
export type ReaderFit = "page" | "width";
export type ReaderPreferencesDto = {
mode: ReaderMode;
fit?: ReaderFit;
};
export function hasActiveCoverWork(jobs: JobDto[]) {
return jobs.some((job) => {
if (job.status !== "queued" && job.status !== "running") return false;
const type = job.type.toLowerCase();
return type.includes("scan") || type.includes("enrich") || type.includes("metadata") || type.includes("cover");
});
}
export function isBookCoverUpdating(book: BookDto, fallbackActive = false) {
const statuses = book as BookDto & { scanStatus?: string; enrichmentStatus?: string };
if (statuses.scanStatus === "running" || statuses.enrichmentStatus === "running") return true;
return statuses.scanStatus === undefined && statuses.enrichmentStatus === undefined && fallbackActive;
}

View File

@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { ApiHttpError } from "../api/client";
import { loginErrorMessage } from "./errors";
describe("login error messages", () => {
it("maps invalid credentials", () => {
expect(loginErrorMessage(new ApiHttpError(401, "Invalid credentials"))).toBe("Identifiants invalides.");
expect(loginErrorMessage(new ApiHttpError(403, "Forbidden"))).toBe("Identifiants invalides.");
});
it("maps server and network errors", () => {
expect(loginErrorMessage(new ApiHttpError(500, "Internal error"))).toBe("Serveur d'authentification indisponible.");
expect(loginErrorMessage(new TypeError("fetch failed"))).toBe("Connexion au serveur impossible.");
});
});

View File

@ -0,0 +1,11 @@
import { ApiHttpError } from "../api/client";
export function loginErrorMessage(error: unknown): string {
if (error instanceof ApiHttpError) {
if (error.status === 401 || error.status === 403) return "Identifiants invalides.";
if (error.status >= 500) return "Serveur d'authentification indisponible.";
return "Connexion impossible.";
}
if (error instanceof TypeError) return "Connexion au serveur impossible.";
return "Connexion impossible.";
}

View File

@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { isPrivateRoute, isPublicRoute } from "./routing";
describe("auth route guards", () => {
it("keeps login and setup public", () => {
expect(isPublicRoute({ name: "login" })).toBe(true);
expect(isPublicRoute({ name: "setup", step: "admin" })).toBe(true);
});
it("marks catalogue routes private", () => {
expect(isPrivateRoute({ name: "home" })).toBe(true);
expect(isPrivateRoute({ name: "search" })).toBe(true);
expect(isPrivateRoute({ name: "book", bookId: 1 })).toBe(true);
});
});

View File

@ -0,0 +1,9 @@
import type { Route } from "../router";
export function isPublicRoute(route: Route): boolean {
return route.name === "login" || route.name === "setup";
}
export function isPrivateRoute(route: Route): boolean {
return !isPublicRoute(route);
}

View File

@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { cleanBookDescription } from "./description";
describe("cleanBookDescription", () => {
it("renders catalog HTML as readable plain text", () => {
expect(cleanBookDescription("<p>Premier &amp; second.</p><p><strong>Suite</strong>&nbsp;du texte.</p>")).toBe(
"Premier & second.\nSuite du texte."
);
});
it("falls back when the description is empty after cleanup", () => {
expect(cleanBookDescription("<p> </p>")).toBe("Notice absente du catalogue.");
});
});

View File

@ -0,0 +1,28 @@
const blockBreakPattern = /<\/(p|div|section|article|header|footer|blockquote|li|ul|ol|br|h[1-6])>/gi;
const tagPattern = /<[^>]*>/g;
function decodeEntities(value: string): string {
if (typeof document === "undefined") {
return value
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'");
}
const textarea = document.createElement("textarea");
textarea.innerHTML = value;
return textarea.value;
}
export function cleanBookDescription(description?: string | null): string {
if (!description) return "Notice absente du catalogue.";
return decodeEntities(description.replace(blockBreakPattern, "\n").replace(tagPattern, " "))
.replace(/\r/g, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n[ \t]+/g, "\n")
.replace(/[ \t]{2,}/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim() || "Notice absente du catalogue.";
}

View File

@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import type { BookDto } from "@readabook/shared";
import {
bookCardVolumeLabel,
bookDisplayTitle,
bookMetadataSourceSummary,
bookMetadataStateLabel,
bookSeriesInfo,
bookSeriesLabel,
bookVolumeLabel,
displayPublishedDate,
jobDigestSummary
} from "./metadata";
type BookFixture = BookDto & {
metadataStatus: "enriched" | "partial" | "none";
metadataProvenance: Record<string, string>;
scanStatus: "idle" | "running" | "succeeded" | "failed";
enrichmentStatus: "idle" | "running" | "succeeded" | "failed";
};
const baseBook: BookFixture = {
id: 1,
libraryId: 1,
title: "Livre test",
author: null,
description: null,
isbn: null,
isbn13: null,
language: null,
publisher: null,
publishedDate: null,
format: "epub",
filePath: "/books/test.epub",
coverPath: null,
metadataStatus: "none",
metadataProvenance: {},
scanStatus: "idle",
enrichmentStatus: "idle",
fileSize: 1,
fileMtime: "2026-08-23T00:00:00.000Z",
createdAt: "2026-08-23T00:00:00.000Z",
updatedAt: "2026-08-23T00:00:00.000Z"
};
describe("book metadata presentation", () => {
it("labels externally enriched books", () => {
const book: BookFixture = { ...baseBook, metadataStatus: "enriched", enrichmentStatus: "succeeded" };
expect(bookMetadataStateLabel(book)).toBe("enrichi");
expect(bookMetadataSourceSummary(book)).toBe("source locale + enrichissement externe");
});
it("labels locally discovered metadata as partial", () => {
const book: BookFixture = { ...baseBook, metadataStatus: "partial", author: "Ada", publishedDate: "1998", scanStatus: "succeeded" };
expect(bookMetadataStateLabel(book)).toBe("partiel");
expect(bookMetadataSourceSummary(book)).toBe("source locale uniquement");
});
it("labels books without exploitable metadata as missing", () => {
expect(bookMetadataStateLabel(baseBook)).toBe("non enrichi");
expect(bookMetadataSourceSummary(baseBook)).toBe("metadata indisponible");
});
it("accepts optional series fields when the backend exposes them", () => {
const book = { ...baseBook, title: "Nom fichier", series: "Cycle", volumeNumber: 2 } as BookDto & { series: string; volumeNumber: number };
expect(bookSeriesLabel(book)).toBe("Cycle · Volume 2");
expect(bookDisplayTitle(book)).toBe("Cycle");
expect(bookVolumeLabel(book)).toBe("Volume 2");
});
it("uses backend series objects and normalized backend volume labels", () => {
const book = {
...baseBook,
title: "Daredevil",
series: { id: 1, title: "Daredevil", normalizedTitle: "daredevil", description: null, publisher: null, createdAt: baseBook.createdAt, updatedAt: baseBook.updatedAt },
volumeNumber: 1,
volumeLabel: "001"
} as BookDto;
expect(bookDisplayTitle(book)).toBe("Daredevil");
expect(bookVolumeLabel(book)).toBe("#1");
expect(bookSeriesLabel(book)).toBe("Daredevil · #1");
});
it("uses compact and unambiguous volume labels on book cards", () => {
expect(bookCardVolumeLabel({ ...baseBook, title: "Solo Leveling T03" })).toBe("T. 3");
expect(bookCardVolumeLabel({ ...baseBook, title: "Archive Volume 12" })).toBe("T. 12");
expect(bookCardVolumeLabel({ ...baseBook, title: "Daredevil #6" })).toBe("#6");
});
it("hides book card volume labels when the number is absent or ambiguous", () => {
const ambiguousBook = { ...baseBook, title: "Nom fichier", series: "Cycle", volumeLabel: "Tome final" } as BookDto & { series: string; volumeLabel: string };
expect(bookCardVolumeLabel(ambiguousBook)).toBeNull();
expect(bookCardVolumeLabel({ ...baseBook, title: "Livre sans tome" })).toBeNull();
});
it("keeps admin job digest synthetic", () => {
expect(
jobDigestSummary({
id: 1,
type: "metadata-enrich",
status: "succeeded",
detail: null,
error: null,
createdAt: baseBook.createdAt,
updatedAt: baseBook.updatedAt
})
).toBe("enrichissement externe");
});
it("hides sentinel and absent publication dates", () => {
expect(displayPublishedDate("0101-01-01T00:00:00+00:00")).toBeNull();
expect(displayPublishedDate(null)).toBeNull();
expect(displayPublishedDate("")).toBeNull();
});
it("renders only the credible publication year", () => {
expect(displayPublishedDate("2007")).toBe("2007");
expect(displayPublishedDate("2007-07-21T00:00:00+00:00")).toBe("2007");
expect(displayPublishedDate("first published in 1998")).toBe("1998");
});
it("normalizes flexible series and volume suffixes from titles", () => {
expect(bookSeriesInfo({ ...baseBook, title: "Daredevil 001" })).toEqual({ title: "Daredevil", volumeLabel: "#1", volumeNumber: 1 });
expect(bookSeriesInfo({ ...baseBook, title: "Daredevil #6" })).toEqual({ title: "Daredevil", volumeLabel: "#6", volumeNumber: 6 });
expect(bookSeriesInfo({ ...baseBook, title: "Solo Leveling T03" })).toEqual({ title: "Solo Leveling", volumeLabel: "Tome 3", volumeNumber: 3 });
expect(bookSeriesInfo({ ...baseBook, title: "Eyeshield 21 T02" })).toEqual({ title: "Eyeshield 21", volumeLabel: "Tome 2", volumeNumber: 2 });
expect(bookSeriesInfo({ ...baseBook, title: "Archive Tome 3" })).toEqual({ title: "Archive", volumeLabel: "Tome 3", volumeNumber: 3 });
expect(bookSeriesInfo({ ...baseBook, title: "Archive Volume 3" })).toEqual({ title: "Archive", volumeLabel: "Volume 3", volumeNumber: 3 });
expect(bookSeriesInfo({ ...baseBook, title: "Archive Issue 6" })).toEqual({ title: "Archive", volumeLabel: "#6", volumeNumber: 6 });
});
});

View File

@ -0,0 +1,171 @@
import type { BookDto, JobDto } from "@readabook/shared";
export type BookMetadataState = "enriched" | "partial" | "missing";
const earliestCrediblePublishedYear = 1450;
export type BookSeriesInfo = {
title: string;
volumeLabel: string | null;
volumeNumber: number | null;
};
type ExtendedBookDto = BookDto & {
scanStatus?: "idle" | "running" | "succeeded" | "failed";
enrichmentStatus?: "idle" | "running" | "succeeded" | "failed";
series?: string | { title?: string | null } | null;
seriesTitle?: string | null;
collection?: string | null;
volumeLabel?: string | null;
seriesIndex?: string | number | null;
seriesNumber?: string | number | null;
volume?: string | number | null;
volumeNumber?: string | number | null;
issue?: string | number | null;
issueNumber?: string | number | null;
};
function hasValue(value: unknown): value is string | number {
if (typeof value === "number") return Number.isFinite(value);
return typeof value === "string" && value.trim().length > 0;
}
export function bookSeriesLabel(book: BookDto): string | null {
const series = bookSeriesInfo(book);
if (!series) return null;
return [series.title, series.volumeLabel].filter(Boolean).join(" · ");
}
function numericValue(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value !== "string") return null;
const match = value.trim().match(/\d+/);
if (!match) return null;
const parsed = Number(match[0]);
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeVolumeLabel(value: unknown, fallbackKind: "tome" | "volume" | "issue" = "volume"): { label: string; number: number | null } | null {
if (!hasValue(value)) return null;
const raw = String(value).trim();
const number = numericValue(raw);
if (!number) return null;
if (/^(t|tome)\s*0*\d+$/i.test(raw)) return { label: `Tome ${number}`, number };
if (/^(vol\.?|volume)\s*0*\d+$/i.test(raw)) return { label: `Volume ${number}`, number };
if (/^(#|issue)\s*0*\d+$/i.test(raw)) return { label: `#${number}`, number };
if (/^0\d{2,}$/.test(raw)) return { label: `#${number}`, number };
if (fallbackKind === "tome") return { label: `Tome ${number}`, number };
if (fallbackKind === "issue") return { label: `#${number}`, number };
return { label: `Volume ${number}`, number };
}
function titleVolumeInfo(title: string): BookSeriesInfo | null {
const trimmed = title.trim();
const patterns: Array<{ pattern: RegExp; kind: "tome" | "volume" | "issue" }> = [
{ pattern: /^(.+?)\s+(T|Tome)\s*0*(\d+)$/i, kind: "tome" },
{ pattern: /^(.+?)\s+(Vol\.?|Volume)\s*0*(\d+)$/i, kind: "volume" },
{ pattern: /^(.+?)\s+(#|Issue)\s*0*(\d+)$/i, kind: "issue" },
{ pattern: /^(.+?)\s+0*(\d{3})$/i, kind: "issue" }
];
for (const { pattern, kind } of patterns) {
const match = trimmed.match(pattern);
if (!match) continue;
const titlePart = match[1]?.trim();
const number = Number(match[3] ?? match[2]);
if (!titlePart || !Number.isFinite(number)) continue;
const normalized = normalizeVolumeLabel(number, kind);
if (!normalized) continue;
return { title: titlePart, volumeLabel: normalized.label, volumeNumber: normalized.number };
}
return null;
}
export function bookSeriesInfo(book: BookDto): BookSeriesInfo | null {
const extended = book as ExtendedBookDto;
const seriesObjectTitle =
extended.series && typeof extended.series === "object" && hasValue(extended.series.title) ? extended.series.title : null;
const series = [seriesObjectTitle, extended.series, extended.seriesTitle, extended.collection].find(hasValue);
if (series) {
const explicitLabel = normalizeVolumeLabel(extended.volumeLabel);
const issue = normalizeVolumeLabel([extended.issueNumber, extended.issue, extended.seriesNumber].find(hasValue), "issue");
const volume = normalizeVolumeLabel([extended.volumeNumber, extended.volume, extended.seriesIndex].find(hasValue), "volume");
const position = explicitLabel ?? issue ?? volume;
return {
title: String(series),
volumeLabel: position?.label ?? null,
volumeNumber: position?.number ?? null
};
}
return titleVolumeInfo(book.title);
}
export function bookDisplayTitle(book: BookDto): string {
return bookSeriesInfo(book)?.title ?? book.title;
}
export function bookVolumeLabel(book: BookDto): string | null {
return bookSeriesInfo(book)?.volumeLabel ?? null;
}
export function bookCardVolumeLabel(book: BookDto): string | null {
const series = bookSeriesInfo(book);
if (!series?.volumeNumber) return null;
if (!series.volumeLabel) return null;
if (series.volumeLabel.startsWith("#")) return series.volumeLabel;
return `T. ${series.volumeNumber}`;
}
export function displayPublishedDate(value?: string | null): string | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
const yearMatch = trimmed.match(/\b(\d{4})\b/);
if (!yearMatch) return null;
const year = Number(yearMatch[1]);
const nextYear = new Date().getFullYear() + 1;
if (!Number.isInteger(year) || year < earliestCrediblePublishedYear || year > nextYear) return null;
return String(year);
}
export function usefulMetadataCount(book: BookDto): number {
return [
book.author,
displayPublishedDate(book.publishedDate),
book.publisher,
book.description,
book.isbn13,
book.isbn,
book.coverPath,
bookSeriesLabel(book)
].filter(hasValue).length;
}
export function bookMetadataState(book: BookDto): BookMetadataState {
const statuses = book as ExtendedBookDto;
if (statuses.enrichmentStatus === "succeeded") return "enriched";
if (usefulMetadataCount(book) > 0 || statuses.scanStatus === "succeeded") return "partial";
return "missing";
}
export function bookMetadataStateLabel(book: BookDto): string {
const state = bookMetadataState(book);
if (state === "enriched") return "enrichi";
if (state === "partial") return "partiel";
return "non enrichi";
}
export function bookMetadataSourceSummary(book: BookDto): string {
const statuses = book as ExtendedBookDto;
if (statuses.enrichmentStatus === "running" || statuses.scanStatus === "running") return "mise a jour en cours";
if (statuses.enrichmentStatus === "succeeded") return "source locale + enrichissement externe";
if (usefulMetadataCount(book) > 0 || statuses.scanStatus === "succeeded") return "source locale uniquement";
return "metadata indisponible";
}
export function jobDigestSummary(job: JobDto): string {
const detail = job.detail?.trim();
if (detail) return detail;
if (job.type.toLowerCase().includes("enrich")) return "enrichissement externe";
if (job.type.toLowerCase().includes("scan")) return "source locale";
return "travail catalogue";
}

View File

@ -1,22 +1,35 @@
import { BookOpen, Eye } from "lucide-react";
import type { BookDto } from "@readabook/shared";
import { api } from "../api/client";
import { bookCardVolumeLabel, bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, displayPublishedDate } from "../book/metadata";
import { navigate } from "../router";
import { FormatPill } from "./ui";
export function BookCard({ book, compact = false }: { book: BookDto; compact?: boolean }) {
export function BookCard({ book, compact = false, coverLoading = false }: { book: BookDto; compact?: boolean; coverLoading?: boolean }) {
const metadataState = bookMetadataState(book);
const publishedDate = displayPublishedDate(book.publishedDate);
const volumeLabel = bookCardVolumeLabel(book);
return (
<article className={`book-card ${compact ? "book-card-compact" : ""}`}>
<button className="cover-button" onClick={() => navigate(`/book/${book.id}`)} aria-label={`Ouvrir ${book.title}`}>
{book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={34} />}
{coverLoading && <span className="cover-loading" aria-label="Jaquette en cours de mise à jour" />}
</button>
<div className="book-card-body">
<div className="book-card-meta">
<FormatPill format={book.format} />
<span>{book.language ?? "langue inconnue"}</span>
{volumeLabel && <span className="volume-pill">{volumeLabel}</span>}
<span className="book-card-language">{book.language ?? "langue inconnue"}</span>
</div>
<h3>{book.title}</h3>
<h3>{bookDisplayTitle(book)}</h3>
<p>{book.author ?? "Auteur inconnu"}</p>
{(volumeLabel || publishedDate) && (
<p className="book-card-submeta">
{[volumeLabel, publishedDate].filter(Boolean).join(" · ")}
</p>
)}
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
{!compact && <p className="book-card-description">{book.description ?? "Notice absente du catalogue."}</p>}
<div className="book-card-actions">
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}>

View File

@ -28,7 +28,7 @@ export function ErrorRibbon({ message }: { message?: string }) {
return <div className="error-ribbon">{message}</div>;
}
export function FormatPill({ format }: { format: "epub" | "pdf" }) {
export function FormatPill({ format }: { format: "epub" | "pdf" | "cbz" | "cbr" }) {
return <span className={`format-pill format-${format}`}>{format.toUpperCase()}</span>;
}

View File

@ -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,10 +7,11 @@ 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 }
];
export function AppShell({ children, session }: { children: ReactNode; session: Session }) {
export function AppShell({ children, session, readerLayout = false }: { children: ReactNode; session: Session; readerLayout?: boolean }) {
return (
<div className="app-shell">
<aside className="side-rail">
@ -31,7 +32,7 @@ export function AppShell({ children, session }: { children: ReactNode; session:
</nav>
<div className="session-chip">{session.user ? session.user.email : "Mode vitrine"}</div>
</aside>
<main>{children}</main>
<main className={readerLayout ? "app-main-reader" : undefined}>{children}</main>
</div>
);
}

View File

@ -0,0 +1,517 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { ArrowDown, ArrowUp, Play, Save } from "lucide-react";
import type {
AutomationFrequency,
AutomationScheduleDto,
AutomationSettingsDto,
MetadataSourcesConfigDto
} from "@readabook/shared";
import { api, getApiFallback } from "../api/client";
import { ErrorRibbon, LoadingState, Panel } from "../components/ui";
import {
type AdminMetadataProviderId,
type AdminMetadataSourcesConfig,
defaultMetadataSources,
metadataSourcesPayload,
moveSource,
normalizeMetadataSources,
providerLabels,
providerUiMessage,
providerUiState,
providerUiStateLabel,
scheduleDays,
scheduleSummary
} from "./adminAutomation";
type AdminAutomationTab = "sources" | "automation";
type ApiState<T> = {
initial: T | null;
draft: T | null;
loading: boolean;
saving: boolean;
error?: string;
success?: string;
};
const defaultMetadataConfig: AdminMetadataSourcesConfig = {
isbnPriorityEnabled: true,
sources: defaultMetadataSources
};
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<AdminAutomationTab>("sources");
const [metadataState, setMetadataState] = useState<ApiState<AdminMetadataSourcesConfig>>({
initial: null,
draft: null,
loading: true,
saving: false
});
const [automationState, setAutomationState] = useState<ApiState<AutomationSettingsDto>>({
initial: null,
draft: null,
loading: true,
saving: false
});
const [apiKeys, setApiKeys] = useState<Partial<Record<AdminMetadataProviderId, string>>>({});
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<MetadataSourcesConfigDto | AdminMetadataSourcesConfig>(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<AutomationSettingsDto>(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 (
<div className="page-grid">
<Panel className="span-3">
<div className="section-heading">
<div>
<h1>Automatisation & enrichissement</h1>
<p className="muted-copy">Sources, priorites et taches recurrentes.</p>
</div>
<span>{metadataDirty || automationDirty ? "modifications non enregistrees" : "a jour"}</span>
</div>
<div className="admin-tabs" role="tablist" aria-label="Automatisation admin">
<button className={tab === "sources" ? "active" : ""} onClick={() => setTab("sources")} role="tab" aria-selected={tab === "sources"}>
Sources de métadonnées
</button>
<button
className={tab === "automation" ? "active" : ""}
onClick={() => setTab("automation")}
role="tab"
aria-selected={tab === "automation"}
>
Automatisation
</button>
</div>
</Panel>
{tab === "sources" ? (
<MetadataSourcesPanel
state={metadataState}
dirty={metadataDirty}
apiKeys={apiKeys}
setApiKeys={setApiKeys}
onChange={(draft) => setMetadataState((current) => ({ ...current, draft, success: undefined }))}
onSubmit={saveMetadata}
onRefresh={refreshMetadata}
/>
) : (
<AutomationPanel
state={automationState}
dirty={automationDirty}
onChange={(draft) => setAutomationState((current) => ({ ...current, draft, success: undefined }))}
onSubmit={saveAutomation}
onRefresh={refreshAutomation}
onRunNow={runNow}
/>
)}
</div>
);
}
function MetadataSourcesPanel({
state,
dirty,
apiKeys,
setApiKeys,
onChange,
onSubmit,
onRefresh
}: {
state: ApiState<AdminMetadataSourcesConfig>;
dirty: boolean;
apiKeys: Partial<Record<AdminMetadataProviderId, string>>;
setApiKeys: (next: Partial<Record<AdminMetadataProviderId, string>>) => void;
onChange: (draft: AdminMetadataSourcesConfig) => void;
onSubmit: (event: FormEvent) => void;
onRefresh: () => Promise<void>;
}) {
const draft = state.draft;
if (state.loading && !draft) return <LoadingPanel label="Lecture des sources" />;
if (!draft) return null;
const local = draft.sources.find((source) => source.provider === "local");
const external = draft.sources.filter((source) => source.provider !== "local");
return (
<form className="span-3 automation-grid" onSubmit={onSubmit}>
<Panel className="span-3">
<div className="section-heading compact-heading">
<div>
<h2>Sources de métadonnées</h2>
<p className="muted-copy">La source locale reste active en premier passage.</p>
</div>
<StatusText dirty={dirty} loading={state.loading} />
</div>
<ErrorRibbon message={state.error} />
{state.success && <div className="success-ribbon">{state.success}</div>}
<label className="toggle-row">
<input
type="checkbox"
checked={draft.isbnPriorityEnabled}
onChange={(event) => onChange({ ...draft, isbnPriorityEnabled: event.target.checked })}
/>
<span>
<strong>Priorite ISBN globale</strong>
<small>Les correspondances ISBN passent avant les rapprochements titre/auteur.</small>
</span>
</label>
</Panel>
<Panel className="span-3">
<div className="provider-list">
{local && (
<div className="provider-row provider-local">
<div>
<strong>{providerLabels.local}</strong>
<span>Source locale</span>
</div>
<span className="status-pill active">toujours active</span>
</div>
)}
{external.map((source, index) => (
<div className="provider-row" key={source.provider}>
<label className="toggle-row">
<input
type="checkbox"
checked={source.enabled}
onChange={(event) =>
onChange({
...draft,
sources: draft.sources.map((item) =>
item.provider === source.provider ? { ...item, enabled: event.target.checked } : item
)
})
}
/>
<span>
<strong>{providerLabels[source.provider]}</strong>
<small>{source.enabled ? "active" : "inactive"}</small>
</span>
</label>
<div className="provider-config">
<div className="provider-state-line">
<span className={`status-pill provider-state-${providerUiState(source)}`}>{providerUiStateLabel(source)}</span>
<small>{providerUiMessage(source)}</small>
</div>
{source.provider === "comicvine" && (
<p className="provider-warning">
Usage non commercial uniquement. Verifier la compatibilite avec l'usage de ReadaBook.
</p>
)}
<label>
Cle API
<input
value={apiKeys[source.provider] ?? ""}
onChange={(event) => setApiKeys({ ...apiKeys, [source.provider]: event.target.value })}
placeholder={source.hasApiKey ? "cle conservee" : source.requiresCredentials ? "requise" : "optionnelle"}
/>
</label>
</div>
<div className="provider-actions">
<button
className="ghost-button icon-button"
type="button"
title="Monter"
disabled={index === 0}
onClick={() => onChange({ ...draft, sources: moveSource(draft.sources, source.provider, -1) })}
>
<ArrowUp size={16} />
</button>
<button
className="ghost-button icon-button"
type="button"
title="Descendre"
disabled={index === external.length - 1}
onClick={() => onChange({ ...draft, sources: moveSource(draft.sources, source.provider, 1) })}
>
<ArrowDown size={16} />
</button>
</div>
</div>
))}
</div>
</Panel>
<SaveBar dirty={dirty} saving={state.saving} onRefresh={onRefresh} />
</form>
);
}
function AutomationPanel({
state,
dirty,
onChange,
onSubmit,
onRefresh,
onRunNow
}: {
state: ApiState<AutomationSettingsDto>;
dirty: boolean;
onChange: (draft: AutomationSettingsDto) => void;
onSubmit: (event: FormEvent) => void;
onRefresh: () => Promise<void>;
onRunNow: (kind: "scan" | "enrich") => Promise<void>;
}) {
const draft = state.draft;
if (state.loading && !draft) return <LoadingPanel label="Lecture de l'automatisation" />;
if (!draft) return null;
return (
<form className="span-3 automation-grid" onSubmit={onSubmit}>
<Panel className="span-3">
<div className="section-heading compact-heading">
<div>
<h2>Automatisation</h2>
<p className="muted-copy">Surveillance des dossiers et traitements planifies.</p>
</div>
<StatusText dirty={dirty} loading={state.loading} />
</div>
<ErrorRibbon message={state.error} />
{state.success && <div className="success-ribbon">{state.success}</div>}
<div className="toggle-stack">
<label className="toggle-row">
<input
type="checkbox"
checked={draft.watchLibraries}
onChange={(event) => onChange({ ...draft, watchLibraries: event.target.checked })}
/>
<span>
<strong>Watch auto</strong>
<small>Les bibliotheques actives declenchent un scan quand un fichier change.</small>
</span>
</label>
<label className="toggle-row">
<input
type="checkbox"
checked={draft.autoEnrichNewBooks}
onChange={(event) => onChange({ ...draft, autoEnrichNewBooks: event.target.checked })}
/>
<span>
<strong>Auto enrich new files</strong>
<small>Les nouveaux livres passent par la chaine d'enrichissement active.</small>
</span>
</label>
</div>
</Panel>
<SchedulePanel
title="Scan planifié"
schedule={draft.scanSchedule}
summary={scheduleSummary(draft.scanSchedule, "Scan")}
runLabel="Lancer un scan"
onRun={() => onRunNow("scan")}
onChange={(scanSchedule) => onChange({ ...draft, scanSchedule })}
/>
<SchedulePanel
title="Enrichissement planifié"
schedule={draft.enrichSchedule}
summary={scheduleSummary(draft.enrichSchedule, "Enrichissement")}
runLabel="Lancer l'enrichissement"
onRun={() => onRunNow("enrich")}
onChange={(enrichSchedule) => onChange({ ...draft, enrichSchedule })}
/>
<SaveBar dirty={dirty} saving={state.saving} onRefresh={onRefresh} />
</form>
);
}
function SchedulePanel({
title,
schedule,
summary,
runLabel,
onRun,
onChange
}: {
title: string;
schedule: AutomationScheduleDto;
summary: string;
runLabel: string;
onRun: () => Promise<void>;
onChange: (schedule: AutomationScheduleDto) => void;
}) {
function patch(next: Partial<AutomationScheduleDto>) {
onChange({ ...schedule, ...next });
}
return (
<Panel>
<div className="section-heading compact-heading">
<div>
<h2>{title}</h2>
<p className="muted-copy">{summary}</p>
</div>
<button className="ghost-button icon-text-button" type="button" onClick={() => void onRun()}>
<Play size={16} />
{runLabel}
</button>
</div>
<div className="schedule-controls">
<label>
Frequence
<select value={schedule.frequency} onChange={(event) => patch({ frequency: event.target.value as AutomationFrequency })}>
<option value="disabled">Desactive</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Heure
<input type="time" value={schedule.time} onChange={(event) => patch({ time: event.target.value })} />
</label>
{schedule.frequency === "weekly" && (
<label>
Jour
<select value={schedule.dayOfWeek} onChange={(event) => patch({ dayOfWeek: Number(event.target.value) })}>
{scheduleDays.map((day) => (
<option value={day.value} key={day.value}>
{day.label}
</option>
))}
</select>
</label>
)}
</div>
</Panel>
);
}
function SaveBar({ dirty, saving, onRefresh }: { dirty: boolean; saving: boolean; onRefresh: () => Promise<void> }) {
return (
<Panel className="span-3 save-bar">
<span>{dirty ? "Modifications en attente." : "Aucune modification en attente."}</span>
<div>
<button className="ghost-button" type="button" onClick={() => void onRefresh()} disabled={saving}>
Recharger
</button>
<button className="primary-button" type="submit" disabled={!dirty || saving}>
<Save size={16} />
{saving ? "Enregistrement" : "Enregistrer"}
</button>
</div>
</Panel>
);
}
function StatusText({ dirty, loading }: { dirty: boolean; loading: boolean }) {
if (loading) return <span>chargement</span>;
return <span>{dirty ? "non enregistre" : "synchronise"}</span>;
}
function LoadingPanel({ label }: { label: string }) {
return (
<Panel className="span-3">
<LoadingState label={label} />
</Panel>
);
}

View File

@ -1,67 +1,152 @@
import { FormEvent, useEffect, useState } from "react";
import { Play, Plus } from "lucide-react";
import { Play, Plus, Trash2 } from "lucide-react";
import type { JobDto, LibraryDto, UserDto } from "@readabook/shared";
import { api } from "../api/client";
import { ErrorRibbon, LoadingState, Panel } from "../components/ui";
import { api, getApiFallback } from "../api/client";
import { jobDigestSummary } from "../book/metadata";
import { EmptyState, ErrorRibbon, LoadingState, Panel } from "../components/ui";
function formatJobTime(value: string) {
return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}
export function AdminPage() {
const [libraries, setLibraries] = useState<LibraryDto[] | null>(null);
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
const [jobs, setJobs] = useState<JobDto[]>([]);
const [users, setUsers] = useState<UserDto[]>([]);
const [loading, setLoading] = useState(true);
const [name, setName] = useState("Bibliotheque locale");
const [path, setPath] = useState("/library");
const [error, setError] = useState<string>();
const [success, setSuccess] = useState<string>();
const [scanRetryLibrary, setScanRetryLibrary] = useState<LibraryDto>();
async function refresh() {
const [nextLibraries, nextJobs, nextUsers] = await Promise.all([api.libraries(), api.jobs(), api.users()]);
setLibraries(nextLibraries);
setJobs(nextJobs);
setUsers(nextUsers);
setLoading(true);
setError(undefined);
const [libraryResult, jobResult, userResult] = await Promise.allSettled([api.libraries(), api.jobs(), api.users()]);
const errors: string[] = [];
if (libraryResult.status === "fulfilled") {
setLibraries(libraryResult.value);
} else {
setLibraries(getApiFallback<LibraryDto[]>(libraryResult.reason) ?? []);
errors.push("bibliotheques");
}
if (jobResult.status === "fulfilled") {
setJobs(jobResult.value);
} else {
setJobs(getApiFallback<JobDto[]>(jobResult.reason) ?? []);
errors.push("travaux");
}
if (userResult.status === "fulfilled") {
setUsers(userResult.value);
} else {
setUsers(getApiFallback<UserDto[]>(userResult.reason) ?? []);
errors.push("comptes");
}
setError(errors.length ? `Donnees admin degradees : ${errors.join(", ")}.` : undefined);
setLoading(false);
}
useEffect(() => {
refresh().catch((refreshError) => setError(refreshError instanceof Error ? refreshError.message : "Administration indisponible"));
void refresh();
}, []);
async function createLibrary(event: FormEvent) {
event.preventDefault();
setError(undefined);
setSuccess(undefined);
setScanRetryLibrary(undefined);
try {
await api.createLibrary({ name, path, enabled: true });
const created = await api.createLibrary({ name, path, enabled: true });
await refresh();
setName("Bibliotheque locale");
setPath("/library");
try {
await api.scanLibrary(created.id);
await refresh();
setSuccess(`Bibliothèque "${created.name}" ajoutée. Scan initial demandé.`);
} catch (scanError) {
setScanRetryLibrary(created);
setSuccess(
`Bibliothèque "${created.name}" ajoutée, mais le scan initial n'a pas pu être demandé. Tu peux réessayer le scan.`
);
setError(scanError instanceof Error ? `Scan initial impossible : ${scanError.message}` : "Scan initial impossible.");
}
} catch (createError) {
setError(createError instanceof Error ? createError.message : "Creation impossible");
setError(createError instanceof Error ? `Création impossible : ${createError.message}` : "Création impossible.");
}
}
async function scan(id: number) {
setError(undefined);
setSuccess(undefined);
setScanRetryLibrary(undefined);
try {
await api.scanLibrary(id);
await refresh();
setSuccess("Scan demandé.");
} catch (scanError) {
setError(scanError instanceof Error ? scanError.message : "Scan impossible");
setError(scanError instanceof Error ? `Scan impossible : ${scanError.message}` : "Scan impossible.");
}
}
if (!libraries) return <LoadingState />;
async function deleteLibrary(library: LibraryDto) {
const confirmed = window.confirm(
`Supprimer la bibliothèque "${library.name}" ?\n\nLes livres restent sur le disque. ReadaBook supprimera seulement cette bibliothèque du catalogue.`
);
if (!confirmed) return;
setError(undefined);
setSuccess(undefined);
setScanRetryLibrary(undefined);
try {
await api.deleteLibrary(library.id);
setLibraries((current) => current.filter((item) => item.id !== library.id));
setSuccess(`Bibliothèque "${library.name}" supprimée. Les fichiers disque n'ont pas été supprimés.`);
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : "Suppression impossible");
}
}
return (
<div className="page-grid">
<Panel className="span-2">
<div className="section-heading">
<h1>Administration</h1>
<span>{users.length} comptes</span>
<span>{loading ? "chargement" : `${users.length} comptes`}</span>
</div>
<ErrorRibbon message={error} />
{success && <div className="success-ribbon">{success}</div>}
{scanRetryLibrary ? (
<div className="retry-row">
<span>La bibliothèque est conservée dans la liste.</span>
<button className="ghost-button" onClick={() => scan(scanRetryLibrary.id)}>
<Play size={16} />
Réessayer le scan
</button>
</div>
) : error ? (
<div className="retry-row">
<span>Les formulaires restent disponibles.</span>
<button className="ghost-button" onClick={() => void refresh()}>
Reessayer
</button>
</div>
) : null}
<form className="admin-form" onSubmit={createLibrary}>
<label>
Nom du rayon
Nom de la bibliothèque
<input value={name} onChange={(event) => setName(event.target.value)} required />
<small>Nom affiché dans lapplication, par exemple : Romans, BD, Documentation.</small>
</label>
<label>
Chemin serveur
Chemin du dossier
<input value={path} onChange={(event) => setPath(event.target.value)} required />
<small>Emplacement réel sur le serveur ReadaBook lit les livres.</small>
</label>
<button className="primary-button" type="submit">
<Plus size={17} />
@ -75,32 +160,54 @@ export function AdminPage() {
<h2>Travaux</h2>
<span>{jobs.length}</span>
</div>
<div className="job-list">
{jobs.map((job) => (
<div key={job.id}>
<strong>{job.type}</strong>
<span>{job.status}</span>
</div>
))}
</div>
{loading && !jobs.length ? (
<LoadingState label="Lecture des travaux" />
) : jobs.length ? (
<div className="job-list">
{jobs.map((job) => (
<div key={job.id}>
<div className="job-copy">
<div>
<strong>{job.type}</strong>
<small>{jobDigestSummary(job)}</small>
</div>
<time dateTime={job.updatedAt}>{formatJobTime(job.updatedAt)}</time>
</div>
<span>{job.status}</span>
</div>
))}
</div>
) : (
<EmptyState title="Aucun travail" detail="Les scans apparaitront ici." />
)}
</Panel>
<Panel className="span-3">
<div className="library-table">
{libraries.map((library) => (
<div key={library.id}>
<div>
<strong>{library.name}</strong>
<span>{library.path}</span>
{loading && !libraries.length ? (
<LoadingState label="Lecture des rayons" />
) : libraries.length ? (
<div className="library-table">
{libraries.map((library) => (
<div key={library.id}>
<div className="library-copy">
<strong>{library.name}</strong>
<span>Chemin : {library.path}</span>
</div>
<span>{library.enabled ? "actif" : "pause"}</span>
<button className="ghost-button" onClick={() => scan(library.id)}>
<Play size={16} />
Scanner
</button>
<button className="ghost-button danger-button" onClick={() => void deleteLibrary(library)}>
<Trash2 size={16} />
Supprimer
</button>
</div>
<span>{library.enabled ? "actif" : "pause"}</span>
<button className="ghost-button" onClick={() => scan(library.id)}>
<Play size={16} />
Scanner
</button>
</div>
))}
</div>
))}
</div>
) : (
<EmptyState title="Aucun rayon" detail="Ajoute un chemin puis relance la synchronisation." />
)}
</Panel>
</div>
);

View File

@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("book missing error pages", () => {
it("does not render fallback books on the book detail page", () => {
const source = readFileSync(new URL("./BookPage.tsx", import.meta.url), "utf8");
expect(source).not.toContain("getApiFallback");
expect(source).toContain("setBook(null)");
expect(source).toContain("Livre introuvable");
expect(source).toContain("Ce livre n'existe pas dans le catalogue.");
});
it("does not open the reader with a fallback book", () => {
const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8");
expect(source).not.toContain("getApiFallback");
expect(source).toContain("setBook(null)");
expect(source).toContain("reader-missing-page");
expect(source).toContain("Livre introuvable");
expect(source).toContain("Ce livre n'existe pas dans le catalogue.");
});
});

View File

@ -1,27 +1,79 @@
import { useEffect, useState } from "react";
import { BookOpen, LibraryBig } from "lucide-react";
import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
import type { BookDto, ProgressDto } from "@readabook/shared";
import { api } from "../api/client";
import { FormatPill, LoadingState, Meter, Panel } from "../components/ui";
import { cleanBookDescription } from "../book/description";
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata";
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
import { navigate } from "../router";
export function BookPage({ bookId }: { bookId: number }) {
const [book, setBook] = useState<BookDto | null>(null);
const [progress, setProgress] = useState<ProgressDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();
async function loadBook() {
setLoading(true);
setError(undefined);
try {
const nextBook = await api.book(bookId);
setBook(nextBook);
try {
setProgress(await api.progress(bookId));
} catch {
setProgress(null);
}
} catch {
setBook(null);
setProgress(null);
setError("Ce livre n'existe pas dans le catalogue.");
} finally {
setLoading(false);
}
}
useEffect(() => {
let alive = true;
Promise.all([api.book(bookId), api.progress(bookId)]).then(([nextBook, nextProgress]) => {
loadBook().finally(() => {
if (!alive) return;
setBook(nextBook);
setProgress(nextProgress);
});
return () => {
alive = false;
};
}, [bookId]);
if (!book) return <LoadingState />;
if (loading && !book) return <LoadingState />;
if (!book) {
return (
<section className="book-error-page" role="alert">
<Panel>
<EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
<div className="book-card-actions">
<button className="ghost-button" onClick={() => navigate("/home")}>
Retour à l'accueil
</button>
<button className="ghost-button" onClick={() => void loadBook()}>
<RotateCcw size={17} />
Reessayer
</button>
</div>
</Panel>
</section>
);
}
const series = bookSeriesInfo(book);
const metadataState = bookMetadataState(book);
const publishedDate = displayPublishedDate(book.publishedDate);
const detailFacts = [
{ label: "Auteur", value: book.author },
{ label: "Date", value: publishedDate },
{ label: "Serie", value: series?.title },
{ label: "Position", value: series?.volumeLabel },
{ label: "Editeur", value: book.publisher },
{ label: "ISBN", value: book.isbn13 ?? book.isbn }
].filter((fact) => fact.value);
return (
<div className="book-detail">
@ -29,13 +81,23 @@ export function BookPage({ bookId }: { bookId: number }) {
{book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={72} />}
</section>
<Panel className="book-facts">
<ErrorRibbon message={error} />
<div className="book-card-meta">
<FormatPill format={book.format} />
<span>{book.language ?? "langue inconnue"}</span>
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
</div>
<h1>{book.title}</h1>
<h1>{bookDisplayTitle(book)}</h1>
<p className="lead">{book.author ?? "Auteur inconnu"}</p>
<p>{book.description ?? "Notice absente du catalogue."}</p>
<dl className="book-fact-list">
{detailFacts.map((fact) => (
<div key={fact.label}>
<dt>{fact.label}</dt>
<dd>{fact.value}</dd>
</div>
))}
</dl>
<p className="book-description">{cleanBookDescription(book.description)}</p>
{progress && <Meter value={progress.percent} />}
<div className="book-card-actions">
<button className="primary-button" onClick={() => navigate(`/reader/${book.id}`)}>
@ -46,6 +108,18 @@ export function BookPage({ bookId }: { bookId: number }) {
<LibraryBig size={18} />
Rayon
</button>
{series && (
<button className="ghost-button" onClick={() => navigate(`/catalog/series/${encodeURIComponent(series.title)}`)}>
<LibraryBig size={18} />
Serie
</button>
)}
{error && (
<button className="ghost-button" onClick={() => void loadBook()}>
<RotateCcw size={18} />
Reessayer
</button>
)}
</div>
</Panel>
</div>

View File

@ -1,21 +1,19 @@
import { useEffect, useState } from "react";
import { LibraryBig, ScanLine } from "lucide-react";
import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
import { api } from "../api/client";
import type { DashboardData } from "../api/types";
import { BookCard } from "../components/BookCard";
import { bookDisplayTitle, bookVolumeLabel } from "../book/metadata";
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
import { navigate } from "../router";
export function HomePage() {
const [state, setState] = useState<DashboardData | null>(null);
const [fallback, setFallback] = useState(false);
useEffect(() => {
let alive = true;
Promise.all([api.books(), api.continueReading(), api.libraries(), api.jobs()])
.then(([books, continueReading, libraries, jobs]) => {
if (!alive) return;
setFallback(books.some((book) => book.filePath.startsWith("/library/")) && jobs.length === 1);
setState({ books, continueReading, libraries, jobs });
})
.catch(() => {
@ -28,13 +26,32 @@ export function HomePage() {
if (!state) return <LoadingState />;
const renderHomeBook = (
item: DashboardData["books"][number],
className = "home-book-card",
options: { href?: string; progressPercent?: number } = {}
) => {
const volumeLabel = bookVolumeLabel(item);
return (
<button key={item.id} className={className} onClick={() => navigate(options.href ?? `/book/${item.id}`)}>
<span className="home-book-cover" aria-hidden="true">
{item.coverPath ? <img src={api.bookCoverUrl(item.id)} alt="" /> : <BookOpen size={24} />}
</span>
<span className="home-book-copy">
{volumeLabel && <span className="home-book-volume">{volumeLabel}</span>}
<strong>{bookDisplayTitle(item)}</strong>
<span>{item.author ?? "Auteur inconnu"}</span>
{options.progressPercent !== undefined && <Meter value={options.progressPercent} />}
</span>
</button>
);
};
return (
<div className="page-grid">
<section className="hero-band">
<div className="page-grid home-page">
<section className="hero-band home-hero">
<div>
<p>Cabinet de curiosites numerique</p>
<h1>Ouvrir, classer, reprendre.</h1>
<span>{fallback ? "API absente ou incomplete : specimens de demonstration actifs." : "Catalogue branche sur le serveur local."}</span>
<h1>Reprendre la lecture.</h1>
</div>
<button className="primary-button" onClick={() => navigate("/search")}>
<ScanLine size={18} />
@ -44,18 +61,17 @@ export function HomePage() {
<Panel className="span-2">
<div className="section-heading">
<h2>Reprise de lecture</h2>
<span>{state.continueReading.length} traces</span>
<h2>Livres en cours</h2>
<span>{state.continueReading.length} lectures</span>
</div>
{state.continueReading.length ? (
<div className="continue-grid">
{state.continueReading.map((item) => (
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}>
<strong>{item.book.title}</strong>
<span>{item.book.author ?? "Auteur inconnu"}</span>
<Meter value={item.progress.percent} />
</button>
))}
{state.continueReading.map((item) =>
renderHomeBook(item.book, "home-book-card continue-tile", {
href: `/reader/${item.book.id}`,
progressPercent: item.progress.percent
})
)}
</div>
) : (
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
@ -77,10 +93,8 @@ export function HomePage() {
</div>
</Panel>
<section className="book-grid span-3">
{state.books.map((book) => (
<BookCard key={book.id} book={book} />
))}
<section className="home-book-grid span-3">
{state.books.map((book) => renderHomeBook(book))}
</section>
</div>
);

View File

@ -0,0 +1,62 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("home progress list layout", () => {
it("keeps the continue reading list scrollable after three visible books", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
const continueTile = styles.match(/\.continue-tile\s*\{[^}]+\}/)?.[0] ?? "";
expect(styles).toContain(".continue-grid,\n.library-list {\n --continue-visible-rows: 3;");
expect(styles).toContain("--continue-tile-block-size: 112px");
expect(styles).toContain("max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))");
expect(styles).toContain("overflow-y: auto");
expect(styles).toContain("scrollbar-gutter: stable");
expect(continueTile).toContain("block-size: var(--continue-tile-block-size)");
expect(continueTile).toContain("overflow: hidden");
});
it("keeps home book cards scoped and simplified", () => {
const source = readFileSync(new URL("./HomePage.tsx", import.meta.url), "utf8");
expect(source).toContain("renderHomeBook");
expect(source).toContain("home-book-card");
expect(source).toContain("progressPercent");
expect(source).toContain("<Meter value={options.progressPercent} />");
expect(source).toContain("progressPercent: item.progress.percent");
expect(source).not.toContain("<BookCard");
expect(source).not.toContain("FormatPill");
expect(source).not.toContain("bookMetadataState");
expect(source).not.toContain("bookMetadataStateLabel");
expect(source).not.toContain("displayPublishedDate");
expect(source).not.toContain("book.description");
expect(source).not.toContain("book.language");
expect(source).not.toContain("API absente ou incomplete");
expect(source).not.toContain("specimens de demonstration actifs");
expect(source).not.toContain("Catalogue branche sur le serveur local");
expect(source).not.toContain("Bibliotheque personnelle");
});
it("keeps visible book grids capped to five columns", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
const bookGrid = styles.match(/\.book-grid\s*\{[^}]+\}/)?.[0] ?? "";
const homeBookGrid = styles.match(/\.home-book-grid\s*\{[^}]+\}/)?.[0] ?? "";
expect(bookGrid).toContain("grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr))");
expect(homeBookGrid).toContain("grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr))");
expect(bookGrid).toContain("width: 100%");
expect(homeBookGrid).toContain("width: 100%");
expect(bookGrid).not.toContain("max-width");
expect(homeBookGrid).not.toContain("max-width");
});
it("keeps the library list capped like continue reading", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
const libraryButton = styles.match(/\.library-list button\s*\{[^}]+\}/)?.[0] ?? "";
expect(styles).toContain(".continue-grid,\n.library-list {\n --continue-visible-rows: 3;");
expect(styles).toContain("height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))");
expect(styles).toContain("max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))");
expect(libraryButton).toContain("block-size: var(--continue-tile-block-size)");
expect(libraryButton).toContain("overflow: hidden");
});
});

View File

@ -1,19 +1,22 @@
import { useEffect, useState } from "react";
import type { BookDto, LibraryDto } from "@readabook/shared";
import type { BookDto, JobDto, LibraryDto } from "@readabook/shared";
import { api } from "../api/client";
import { hasActiveCoverWork, isBookCoverUpdating } from "../api/types";
import { BookCard } from "../components/BookCard";
import { EmptyState, LoadingState, Panel } from "../components/ui";
export function LibraryPage({ libraryId }: { libraryId: number }) {
const [books, setBooks] = useState<BookDto[] | null>(null);
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
const [jobs, setJobs] = useState<JobDto[]>([]);
useEffect(() => {
let alive = true;
Promise.all([api.books({ libraryId }), api.libraries()]).then(([nextBooks, nextLibraries]) => {
Promise.all([api.books({ libraryId }), api.libraries(), api.jobs().catch(() => [])]).then(([nextBooks, nextLibraries, nextJobs]) => {
if (!alive) return;
setBooks(nextBooks);
setLibraries(nextLibraries);
setJobs(nextJobs);
});
return () => {
alive = false;
@ -22,6 +25,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) {
if (!books) return <LoadingState />;
const library = libraries.find((item) => item.id === libraryId);
const fallbackCoverLoading = hasActiveCoverWork(jobs);
return (
<div className="page-grid">
@ -37,7 +41,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) {
{books.length ? (
<section className="book-grid span-3">
{books.map((book) => (
<BookCard key={book.id} book={book} />
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
))}
</section>
) : (

View File

@ -1,14 +1,36 @@
import { FormEvent, useState } from "react";
import { KeyRound, LogIn } from "lucide-react";
import { FormEvent, useEffect, useState } from "react";
import { KeyRound, LogIn, ShieldAlert } from "lucide-react";
import type { AuthStatusDto } from "@readabook/shared";
import { api } from "../api/client";
import { loginErrorMessage } from "../auth/errors";
import { navigate } from "../router";
import { ErrorRibbon, Panel } from "../components/ui";
const DEFAULT_INITIAL_PASSWORD = "readabook-admin-change-me";
export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<void> }) {
const [email, setEmail] = useState("admin@readabook.local");
const [status, setStatus] = useState<AuthStatusDto | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string>();
useEffect(() => {
let alive = true;
api
.authStatus()
.then((nextStatus) => {
if (!alive) return;
setStatus(nextStatus);
setEmail((current) => current || nextStatus.initialAdminEmail);
})
.catch(() => {
if (alive) setError("Statut d'authentification indisponible.");
});
return () => {
alive = false;
};
}, []);
async function submit(event: FormEvent) {
event.preventDefault();
setError(undefined);
@ -17,7 +39,7 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
await onSessionChange();
navigate("/home");
} catch (loginError) {
setError(loginError instanceof Error ? loginError.message : "Connexion impossible");
setError(loginErrorMessage(loginError));
}
}
@ -32,6 +54,23 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
<KeyRound size={24} />
<h2>Entrer dans le cabinet</h2>
<ErrorRibbon message={error} />
{status?.hasUsers && (
<div className="initial-admin-box">
<ShieldAlert size={18} />
<div>
<strong>Acces admin initial</strong>
<span>{status.initialAdminEmail}</span>
{status.initialAdminPasswordIsDefault ? (
<>
<code>{DEFAULT_INITIAL_PASSWORD}</code>
<small>Mot de passe par defaut atteste par le serveur. Change-le dans Mon compte &gt; Securite.</small>
</>
) : (
<small>Utilise le mot de passe configure au demarrage ou deja modifie dans le compte.</small>
)}
</div>
</div>
)}
<form onSubmit={submit} className="stack-form">
<label>
Email
@ -46,9 +85,11 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
Se connecter
</button>
</form>
<button className="ghost-button full-width" onClick={() => navigate("/setup/admin")}>
Initialiser le premier admin
</button>
{status && !status.hasUsers && (
<button className="ghost-button full-width" onClick={() => navigate("/setup/admin")}>
Initialiser le premier admin
</button>
)}
</Panel>
</div>
);

View File

@ -1,27 +1,95 @@
import { LogOut, UserRound } from "lucide-react";
import { FormEvent, useState } from "react";
import { KeyRound, LogOut, UserRound } from "lucide-react";
import type { Session } from "../api/types";
import { api } from "../api/client";
import { Panel } from "../components/ui";
import { ErrorRibbon, Panel } from "../components/ui";
import { navigate } from "../router";
export function ProfilePage({ session, onSessionChange }: { session: Session; onSessionChange: () => Promise<void> }) {
const [email, setEmail] = useState(session.user?.email ?? "");
const [name, setName] = useState(session.user?.name ?? "");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [error, setError] = useState<string>();
const [success, setSuccess] = useState<string>();
async function logout() {
await api.logout();
await onSessionChange();
navigate("/login");
}
async function updateSecurity(event: FormEvent) {
event.preventDefault();
setError(undefined);
setSuccess(undefined);
try {
await api.updateMe({
email: email === session.user?.email ? undefined : email,
name: name || null,
currentPassword,
newPassword: newPassword || undefined
});
setCurrentPassword("");
setNewPassword("");
setSuccess("Identifiants mis a jour.");
await onSessionChange();
} catch (updateError) {
setError(updateError instanceof Error ? updateError.message : "Mise a jour impossible");
}
}
return (
<div className="page-grid">
<Panel className="span-2 profile-panel">
<UserRound size={28} />
<h1>{session.user?.name ?? "Lecteur invite"}</h1>
<p>{session.user?.email ?? "Session non connectee"}</p>
<span>{session.user?.role ?? "vitrine"}</span>
<button className="ghost-button" onClick={logout}>
<LogOut size={17} />
Sortir
</button>
<Panel className="profile-panel">
<div className="profile-identity">
<span className="profile-avatar" aria-hidden="true">
<UserRound size={28} />
</span>
<h1>{session.user?.name ?? "Lecteur invite"}</h1>
<p>{session.user?.email ?? "Session non connectee"}</p>
<span>{session.user?.role ?? "vitrine"}</span>
</div>
<div className="profile-actions">
<button className="ghost-button" onClick={() => document.getElementById("profile-security-form")?.scrollIntoView({ block: "start" })}>
<KeyRound size={17} />
Modifier
</button>
<button className="ghost-button" onClick={logout}>
<LogOut size={17} />
Sortir
</button>
</div>
</Panel>
<Panel className="span-2">
<div className="section-heading">
<h2>Securite</h2>
<KeyRound size={20} />
</div>
<p className="muted-copy">Change l'email et le mot de passe admin initial des que le cabinet est installe.</p>
<ErrorRibbon message={error} />
{success && <div className="success-ribbon">{success}</div>}
<form id="profile-security-form" className="stack-form" onSubmit={updateSecurity}>
<label>
Email
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required />
</label>
<label>
Nom
<input value={name} onChange={(event) => setName(event.target.value)} />
</label>
<label>
Mot de passe actuel
<input value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} type="password" required />
</label>
<label>
Nouveau mot de passe
<input value={newPassword} onChange={(event) => setNewPassword(event.target.value)} type="password" minLength={8} />
</label>
<button className="primary-button" type="submit">
Enregistrer
</button>
</form>
</Panel>
</div>
);

View File

@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("profile page polish", () => {
it("focuses the left column on identity and immediate actions", () => {
const source = readFileSync(new URL("./ProfilePage.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
expect(source).toContain("profile-identity");
expect(source).toContain("profile-avatar");
expect(source).toContain("profile-actions");
expect(source).toContain('id="profile-security-form"');
expect(source).toContain("scrollIntoView");
expect(styles).toContain(".profile-identity");
expect(styles).toContain(".profile-actions");
});
});

View File

@ -1,57 +1,306 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowLeft, Save } from "lucide-react";
import { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react";
import type { BookDto } from "@readabook/shared";
import { api } from "../api/client";
import { LoadingState, Meter } from "../components/ui";
import { navigate } from "../router";
import { CbzReader } from "../reader/CbzReader";
import { EpubReader } from "../reader/EpubReader";
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
import { PdfReader } from "../reader/PdfReader";
import { ReaderShell, type ReaderControls, type ReaderModeControls, type ReaderZoomAnchor, type ReaderZoomControls } from "../reader/ReaderShell";
import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reader/readerLayout";
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
import { useReaderPreferences } from "../reader/useReaderPreferences";
import { useReaderProgress } from "../reader/useReaderProgress";
import { navigate } from "../router";
import { EmptyState, LoadingState, Panel } from "../components/ui";
const idleControls: ReaderControls = {
canPrevious: false,
canNext: false,
positionLabel: "Chargement",
onPrevious: () => undefined,
onNext: () => undefined
};
type ReaderCrashBoundaryProps = {
resetKey: string;
onError: (error: Error) => void;
fallbackRender: (error: Error, retry: () => void) => ReactNode;
children: ReactNode;
};
type ReaderCrashBoundaryState = {
error: Error | null;
};
class ReaderCrashBoundary extends Component<ReaderCrashBoundaryProps, ReaderCrashBoundaryState> {
state: ReaderCrashBoundaryState = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, _errorInfo: ErrorInfo) {
this.props.onError(error);
}
componentDidUpdate(previousProps: ReaderCrashBoundaryProps) {
if (previousProps.resetKey !== this.props.resetKey && this.state.error) {
this.setState({ error: null });
}
}
retry = () => {
this.setState({ error: null });
};
render() {
if (this.state.error) return this.props.fallbackRender(this.state.error, this.retry);
return this.props.children;
}
}
export function ReaderPage({ bookId }: { bookId: number }) {
const [book, setBook] = useState<BookDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();
const [page, setPage] = useState(1);
const { progress, saving, save } = useReaderProgress(bookId);
const [zoom, setZoom] = useState(READER_ZOOM_DEFAULT);
const [mode, setMode] = useState<ReaderMode>("horizontal");
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId);
async function loadBook() {
setLoading(true);
setError(undefined);
try {
setBook(await api.book(bookId));
} catch {
setBook(null);
setError("Ce livre n'existe pas dans le catalogue.");
} finally {
setLoading(false);
}
}
useEffect(() => {
api.book(bookId).then(setBook);
void loadBook();
}, [bookId]);
useEffect(() => {
if (progress?.locator.startsWith("pdf:page:")) setPage(Number(progress.locator.split(":").at(-1)) || 1);
setReaderControls(idleControls);
setPage(1);
setZoom(READER_ZOOM_DEFAULT);
setMode("horizontal");
}, [bookId]);
useEffect(() => {
if (book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr") setMode(preferences.mode);
else setMode("horizontal");
}, [book?.format, preferences.mode]);
useEffect(() => {
const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator);
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
}, [progress]);
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
const savePdfPage = useCallback(
(nextPage: number, pages: number) => {
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
setPage(nextPage);
void save(`pdf:page:${nextPage}`, Math.round((nextPage / pages) * 100));
const locator = pageLocator("pdf", nextPage, anchor);
const percent = pdfPagePercent(nextPage, pages, anchor);
if (strategy === "queued") queueSave(locator, percent);
else void save(locator, percent);
},
[save]
[queueSave, save]
);
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
const saveComicPage = useCallback(
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
setPage(nextPage);
const prefix = book?.format === "cbr" ? "cbr" : "cbz";
const locator = pageLocator(prefix, nextPage, anchor);
const percent = pdfPagePercent(nextPage, pages, anchor);
if (strategy === "queued") queueSave(locator, percent);
else void save(locator, percent);
},
[book?.format, queueSave, save]
);
if (!book) return <LoadingState label="Ouverture du lecteur" />;
const readerError = error ?? progressError ?? preferencesError;
const supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr";
const supportsMode = supportsZoom;
const currentVisiblePage = useCallback(() => {
const stage = document.querySelector(".reader-stage") as HTMLElement | null;
if (!stage) return null;
const stageRect = stage.getBoundingClientRect();
const pages = Array.from(stage.querySelectorAll<HTMLElement>("[data-reader-page]"))
.map((element) => {
const rect = element.getBoundingClientRect();
const pageNumber = Number(element.dataset.readerPage);
return Number.isFinite(pageNumber) ? { page: pageNumber, top: rect.top, bottom: rect.bottom } : null;
})
.filter((item): item is { page: number; top: number; bottom: number } => Boolean(item));
return majorityVisiblePage(pages, stageRect.top, stageRect.bottom);
}, []);
const changeMode = useCallback(
(nextMode: ReaderMode) => {
const anchorPage = currentVisiblePage() ?? page;
setPage(anchorPage);
setMode(nextMode);
saveReaderMode(nextMode);
},
[currentVisiblePage, page, saveReaderMode]
);
const returnToPagedMode = useCallback(() => {
setPage(currentVisiblePage() ?? page);
setMode("horizontal");
saveReaderMode("horizontal");
}, [currentVisiblePage, page, saveReaderMode]);
const changeZoom = useCallback((nextZoom: number | ((currentZoom: number) => number), anchor?: ReaderZoomAnchor) => {
const stage = document.querySelector(".reader-stage") as HTMLElement | null;
const scrollRatioX = stage && stage.scrollWidth > stage.clientWidth ? (stage.scrollLeft + stage.clientWidth / 2) / stage.scrollWidth : 0.5;
const scrollRatioY = stage && stage.scrollHeight > stage.clientHeight ? stage.scrollTop / (stage.scrollHeight - stage.clientHeight) : 0;
setZoom((currentZoom) => clampReaderZoom(typeof nextZoom === "function" ? nextZoom(currentZoom) : nextZoom));
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!stage) return;
if (anchor) {
const target = stage.querySelector<HTMLElement>(`[data-reader-page="${anchor.page}"]`);
if (!target) return;
const rect = target.getBoundingClientRect();
stage.scrollLeft += rect.left + anchor.offsetX - anchor.clientX;
stage.scrollTop += rect.top + anchor.offsetY - anchor.clientY;
return;
}
stage.scrollLeft = Math.max(0, stage.scrollWidth * scrollRatioX - stage.clientWidth / 2);
stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) * scrollRatioY);
});
});
}, []);
const zoomControls = useMemo<ReaderZoomControls | undefined>(
() =>
supportsZoom
? {
zoom,
onZoomChange: (nextZoom, anchor) => changeZoom(nextZoom, anchor),
onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP),
onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP),
onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT)
}
: undefined,
[changeZoom, supportsZoom, zoom]
);
const modeControls = useMemo<ReaderModeControls | undefined>(
() =>
supportsMode
? {
mode,
onModeChange: changeMode
}
: undefined,
[changeMode, mode, supportsMode]
);
if (loading && !book) return <LoadingState />;
if (!book) {
return (
<section className="reader-missing-page" role="alert">
<Panel>
<EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
<div className="reader-error-actions">
<button className="ghost-button" onClick={() => navigate("/home")}>
Retour à l'accueil
</button>
<button className="ghost-button" onClick={() => void loadBook()}>
Réessayer
</button>
</div>
</Panel>
</section>
);
}
return (
<div className="reader-page">
<header className="reader-topbar">
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}>
<ArrowLeft size={17} />
Fiche
</button>
<div>
<strong>{book.title}</strong>
<span>{saving ? "Sauvegarde" : "Progression synchronisee"}</span>
</div>
<Save size={18} />
</header>
<Meter value={progress?.percent ?? 0} />
{book.format === "pdf" ? (
<PdfReader url={fileUrl} page={page} onPageChange={savePdfPage} />
) : (
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} />
)}
</div>
<ReaderShell
title={book?.title ?? "Ouverture du lecteur"}
backHref={backHref}
error={readerError}
onRetry={error ? () => void loadBook() : undefined}
controls={readerControls}
zoomControls={zoomControls}
modeControls={modeControls}
>
<ReaderCrashBoundary
resetKey={`${bookId}:${book?.format ?? "loading"}:${mode}`}
onError={() => setReaderControls(idleControls)}
fallbackRender={(crashError, retry) => (
<div className="reader-error" role="alert">
<div>
<h2>Le lecteur a rencontré une erreur.</h2>
<p>La page reste ouverte. Vous pouvez réessayer, revenir au mode page par page ou retourner à la fiche du livre.</p>
</div>
<div className="reader-error-actions">
<button className="ghost-button" onClick={retry}>
Réessayer
</button>
<button
className="ghost-button"
onClick={() => {
returnToPagedMode();
retry();
}}
>
Revenir au mode page par page
</button>
<button className="ghost-button" onClick={() => navigate(backHref)}>
Retour à la fiche
</button>
</div>
{crashError.message && (
<details>
<summary>Détail technique</summary>
<pre>{crashError.message}</pre>
</details>
)}
</div>
)}
>
{book.format === "pdf" ? (
<PdfReader
url={fileUrl}
page={page}
backHref={backHref}
zoom={zoom}
mode={mode}
onPageCommit={savePdfPage}
onControlsChange={setReaderControls}
/>
) : book.format === "cbz" || book.format === "cbr" ? (
<CbzReader
bookId={book.id}
page={page}
zoom={zoom}
mode={mode}
onPageCommit={saveComicPage}
onControlsChange={setReaderControls}
/>
) : (
<EpubReader
url={fileUrl}
locator={progress?.locator}
backHref={backHref}
mode="horizontal"
onLocatorChange={saveEpubLocator}
onControlsChange={setReaderControls}
/>
)}
</ReaderCrashBoundary>
</ReaderShell>
);
}

View File

@ -0,0 +1,20 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("ReaderPage crash containment", () => {
it("keeps a reader shell or fallback mounted when the CBZ vertical reader crashes", () => {
const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8");
expect(source).toContain("<ReaderShell");
expect(source).toMatch(/Reader(ErrorBoundary|CrashBoundary)|componentDidCatch|fallbackRender|onError/);
});
it("keeps explicit recovery actions in the reader crash fallback", () => {
const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8");
expect(source).toContain("Le lecteur a rencontré une erreur.");
expect(source).toContain("Réessayer");
expect(source).toContain("Revenir au mode page par page");
expect(source).toContain("Retour à la fiche");
});
});

View File

@ -0,0 +1,177 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type ReaderPageElement = {
dataset: { readerPage: string };
getBoundingClientRect: () => { top: number; bottom: number };
scrollIntoView: ReturnType<typeof vi.fn>;
};
const runtime = vi.hoisted(() => ({
stateIndex: 0,
refIndex: 0,
states: [] as unknown[],
frame: null as { closest: ReturnType<typeof vi.fn>; querySelector: ReturnType<typeof vi.fn> } | null,
previousMode: "vertical",
effects: [] as Array<() => void | (() => void)>,
pageElements: [] as ReaderPageElement[]
}));
vi.mock("react", async () => {
const actual = await vi.importActual<typeof import("react")>("react");
return {
...actual,
useCallback: (callback: unknown) => callback,
useEffect: (effect: () => void | (() => void)) => {
runtime.effects.push(effect);
},
useRef: (initial: unknown) => {
if (runtime.refIndex === 0) {
runtime.refIndex += 1;
return { current: runtime.frame };
}
runtime.refIndex += 1;
return { current: initial };
},
useState: (initial: unknown) => {
const index = runtime.stateIndex;
runtime.stateIndex += 1;
return [runtime.states[index] ?? initial, vi.fn()];
}
};
});
vi.mock("../api/client", () => ({
api: {
cbzPages: vi.fn().mockResolvedValue({
bookId: 39,
pageCount: 144,
pages: Array.from({ length: 144 }, (_, index) => ({ page: index + 1, name: `page-${index + 1}.jpg` }))
}),
cbzPageUrl: (bookId: number, page: number) => `/books/${bookId}/pages/${page}`
}
}));
import { CbzReader } from "../reader/CbzReader";
type ElementLike = {
type: unknown;
props?: Record<string, unknown> & { children?: unknown };
};
function isElementLike(value: unknown): value is ElementLike {
return Boolean(value && typeof value === "object" && "type" in value);
}
function findElementsByType(node: unknown, type: string): ElementLike[] {
if (Array.isArray(node)) return node.flatMap((child) => findElementsByType(child, type));
if (!isElementLike(node)) return [];
const matches = node.type === type ? [node] : [];
return [...matches, ...findElementsByType(node.props?.children, type)];
}
function pageElement(page: number, top: number, height = 1000): ReaderPageElement {
return {
dataset: { readerPage: String(page) },
getBoundingClientRect: () => ({ top, bottom: top + height }),
scrollIntoView: vi.fn()
};
}
function runEffects() {
for (const effect of runtime.effects) effect();
}
describe("ReaderPage vertical restore", () => {
beforeEach(() => {
runtime.stateIndex = 0;
runtime.refIndex = 0;
runtime.effects = [];
runtime.pageElements = Array.from({ length: 144 }, (_, index) => pageElement(index + 1, index * 1000));
runtime.frame = {
closest: vi.fn(() => ({
getBoundingClientRect: () => ({ top: 0, bottom: 900 }),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
querySelectorAll: vi.fn(() => runtime.pageElements)
})),
querySelector: vi.fn((selector: string) => {
const match = selector.match(/\[data-reader-page="(\d+)"\]/);
return match ? runtime.pageElements[Number(match[1]) - 1] : null;
})
};
runtime.states = [
{
bookId: 39,
pageCount: 144,
pages: Array.from({ length: 144 }, (_, index) => ({ page: index + 1, name: `page-${index + 1}.jpg` }))
},
undefined,
undefined,
0,
0,
{ width: 900, height: 900 },
null,
{}
];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
vi.stubGlobal(
"ResizeObserver",
vi.fn(() => ({
observe: vi.fn(),
disconnect: vi.fn()
}))
);
});
it("anchors page 73 of a CBZ/CBR and keeps previous pages reachable in vertical mode", () => {
let controls: { onPrevious: () => void } | undefined;
const onPageCommit = vi.fn();
CbzReader({
bookId: 39,
page: 73,
zoom: 100,
mode: "vertical",
onPageCommit,
onControlsChange: (nextControls) => {
controls = nextControls;
}
});
runEffects();
expect(runtime.pageElements[72].scrollIntoView).toHaveBeenCalledWith({ block: "start" });
runtime.stateIndex = 0;
runtime.refIndex = 0;
runtime.effects = [];
const tree = CbzReader({
bookId: 39,
page: 73,
zoom: 100,
mode: "vertical",
onPageCommit,
onControlsChange: vi.fn()
});
const figures = findElementsByType(tree, "figure");
const images = findElementsByType(tree, "img");
expect(figures).toHaveLength(144);
expect(figures[0].props?.["data-reader-page"]).toBe(1);
expect(figures[71].props?.["data-reader-page"]).toBe(72);
expect(figures[72].props?.["data-reader-page"]).toBe(73);
expect(images[0].props?.src).toBe("/books/39/pages/1");
expect(images[71].props?.src).toBe("/books/39/pages/72");
expect(images[71].props?.loading).toBe("lazy");
expect(images[71].props).not.toHaveProperty("hidden");
controls?.onPrevious();
expect(runtime.pageElements[71].scrollIntoView).toHaveBeenCalledWith({ block: "start" });
expect(onPageCommit).toHaveBeenCalledWith(72, 144, 1, "immediate");
});
});

View File

@ -1,22 +1,43 @@
import { FormEvent, useEffect, useState } from "react";
import { Search } from "lucide-react";
import type { BookDto } from "@readabook/shared";
import { api } from "../api/client";
import type { BookDto, JobDto } from "@readabook/shared";
import { api, getApiFallback } from "../api/client";
import { hasActiveCoverWork, isBookCoverUpdating } from "../api/types";
import { BookCard } from "../components/BookCard";
import { EmptyState, LoadingState, Panel } from "../components/ui";
export function SearchPage() {
const [query, setQuery] = useState("");
const [books, setBooks] = useState<BookDto[] | null>(null);
const [books, setBooks] = useState<BookDto[]>([]);
const [jobs, setJobs] = useState<JobDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();
async function loadBooks(nextQuery = query) {
setLoading(true);
setError(undefined);
try {
const [nextBooks, nextJobs] = await Promise.all([nextQuery.trim() ? api.search(nextQuery.trim()) : api.books(), api.jobs().catch(() => [])]);
setBooks(nextBooks);
setJobs(nextJobs);
} catch (loadError) {
const fallback = getApiFallback<BookDto[]>(loadError);
setBooks(fallback ?? []);
setError(fallback ? "Catalogue indisponible, affichage de secours." : "Recherche indisponible.");
} finally {
setLoading(false);
}
}
const fallbackCoverLoading = hasActiveCoverWork(jobs);
useEffect(() => {
api.books().then(setBooks);
void loadBooks("");
}, []);
async function submit(event: FormEvent) {
event.preventDefault();
setBooks(null);
setBooks(query.trim() ? await api.search(query.trim()) : await api.books());
await loadBooks(query);
}
return (
@ -29,18 +50,29 @@ export function SearchPage() {
Chercher
</button>
</form>
{error && (
<div className="retry-row">
<span>{error}</span>
<button className="ghost-button" onClick={() => void loadBooks(query)}>
Reessayer
</button>
</div>
)}
</Panel>
{!books ? (
{loading && !books.length ? (
<LoadingState />
) : books.length ? (
<section className="book-grid span-3">
{books.map((book) => (
<BookCard key={book.id} book={book} />
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
))}
</section>
) : (
<Panel className="span-3">
<EmptyState title="Aucun specimen" detail="Essaie un autre terme ou relance l'indexation." />
<EmptyState
title={loading ? "Recherche en cours" : "Aucun specimen"}
detail={loading ? "Le formulaire reste disponible." : "Essaie un autre terme ou relance l'indexation."}
/>
</Panel>
)}
</div>

View File

@ -0,0 +1,53 @@
import { useEffect, useMemo, useState } from "react";
import type { BookDto } from "@readabook/shared";
import { api } from "../api/client";
import { bookSeriesInfo } from "../book/metadata";
import { BookCard } from "../components/BookCard";
import { EmptyState, LoadingState, Panel } from "../components/ui";
export function SeriesPage({ seriesName }: { seriesName: string }) {
const [books, setBooks] = useState<BookDto[] | null>(null);
useEffect(() => {
let alive = true;
api.books().then((nextBooks) => {
if (alive) setBooks(nextBooks);
});
return () => {
alive = false;
};
}, []);
const seriesBooks = useMemo(() => {
const expected = seriesName.trim().toLocaleLowerCase();
return (books ?? [])
.filter((book) => bookSeriesInfo(book)?.title.trim().toLocaleLowerCase() === expected)
.sort((left, right) => (bookSeriesInfo(left)?.volumeNumber ?? Number.MAX_SAFE_INTEGER) - (bookSeriesInfo(right)?.volumeNumber ?? Number.MAX_SAFE_INTEGER));
}, [books, seriesName]);
if (!books) return <LoadingState />;
return (
<div className="page-grid">
<Panel className="span-3">
<div className="section-heading">
<div>
<h1>{seriesName}</h1>
<p>{seriesBooks.length} volumes reperes</p>
</div>
</div>
</Panel>
{seriesBooks.length ? (
<section className="book-grid span-3">
{seriesBooks.map((book) => (
<BookCard key={book.id} book={book} />
))}
</section>
) : (
<Panel className="span-3">
<EmptyState title="Serie introuvable" detail="Les volumes apparaitront ici quand le catalogue exposera leur serie." />
</Panel>
)}
</div>
);
}

View File

@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import type { AdminMetadataSourcesConfig } from "./adminAutomation";
import {
metadataSourcesPayload,
moveSource,
normalizeMetadataSources,
providerLabels,
providerUiMessage,
providerUiStateLabel,
scheduleSummary
} from "./adminAutomation";
const config: AdminMetadataSourcesConfig = {
isbnPriorityEnabled: true,
sources: [
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: true },
{ provider: "local", enabled: false, priority: 99, hasApiKey: false },
{ provider: "openlibrary", enabled: true, priority: 1, hasApiKey: false },
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false },
{ provider: "comicvine", enabled: true, priority: 4, hasApiKey: false, requiresCredentials: true },
{ provider: "mangadex", enabled: false, priority: 5, hasApiKey: false }
]
};
describe("admin automation helpers", () => {
it("keeps the local metadata source active and first", () => {
expect(normalizeMetadataSources(config).sources[0]).toMatchObject({
provider: "local",
enabled: true,
priority: 0
});
});
it("excludes the local source from the update payload", () => {
expect(metadataSourcesPayload(normalizeMetadataSources(config))).toEqual({
isbnPriorityEnabled: true,
sources: [
{ provider: "openlibrary", enabled: true, priority: 1 },
{ provider: "googlebooks", enabled: false, priority: 2 },
{ provider: "bnf", enabled: false, priority: 3 },
{ provider: "comicvine", enabled: true, priority: 4 },
{ provider: "mangadex", enabled: false, priority: 5 }
]
});
});
it("moves only external providers", () => {
const moved = moveSource(normalizeMetadataSources(config).sources, "bnf", -1);
expect(moved.map((source) => source.provider)).toEqual(["local", "openlibrary", "bnf", "googlebooks", "comicvine", "mangadex"]);
});
it("summarizes weekly schedules", () => {
expect(scheduleSummary({ frequency: "weekly", time: "04:30", dayOfWeek: 1 }, "Scan")).toBe("Scan chaque lundi a 04:30.");
});
it("adds Comic Vine and MangaDex when the backend omits them", () => {
const normalized = normalizeMetadataSources({
isbnPriorityEnabled: true,
sources: [{ provider: "local", enabled: true, priority: 0, hasApiKey: false }]
});
expect(normalized.sources.map((source) => source.provider)).toContain("comicvine");
expect(normalized.sources.map((source) => source.provider)).toContain("mangadex");
expect(providerLabels.comicvine).toBe("Comic Vine");
expect(providerLabels.mangadex).toBe("MangaDex");
});
it("labels provider configuration, rate limit and error states", () => {
expect(providerUiStateLabel({ provider: "comicvine", enabled: true, priority: 1, hasApiKey: false, requiresCredentials: true })).toBe(
"A configurer"
);
expect(providerUiMessage({ provider: "comicvine", enabled: true, priority: 1, hasApiKey: false, requiresCredentials: true })).toBe(
"Source activee, configuration incomplete."
);
expect(providerUiStateLabel({ provider: "mangadex", enabled: true, priority: 2, hasApiKey: false, rateLimited: true })).toBe("Limite");
expect(providerUiMessage({ provider: "mangadex", enabled: true, priority: 2, hasApiKey: false, status: "quota_exceeded" })).toBe(
"Quota ou limite temporaire atteint. ReadaBook reessaiera plus tard."
);
expect(providerUiStateLabel({ provider: "mangadex", enabled: true, priority: 2, hasApiKey: false, lastError: "500 stack" })).toBe("Erreur");
});
});

View File

@ -0,0 +1,132 @@
import type {
AutomationScheduleDto,
MetadataProviderId,
MetadataSourceConfigDto,
MetadataSourcesConfigDto,
UpdateMetadataSourcesConfigDto
} from "@readabook/shared";
export type AdminMetadataProviderId = MetadataProviderId | "comicvine" | "mangadex";
export type AdminMetadataSourceConfig = Omit<MetadataSourceConfigDto, "provider"> & {
provider: AdminMetadataProviderId;
requiresCredentials?: boolean;
status?: string | null;
state?: string | null;
health?: string | null;
message?: string | null;
lastError?: string | null;
rateLimited?: boolean;
quotaLimited?: boolean;
};
export type AdminMetadataSourcesConfig = Omit<MetadataSourcesConfigDto, "sources"> & {
sources: AdminMetadataSourceConfig[];
};
export type ProviderUiState = "configured" | "missing-config" | "limited" | "error";
export const providerLabels: Record<AdminMetadataProviderId, string> = {
local: "Fichier local",
openlibrary: "OpenLibrary",
googlebooks: "Google Books",
bnf: "BnF",
comicvine: "Comic Vine",
mangadex: "MangaDex"
};
export const defaultMetadataSources: AdminMetadataSourceConfig[] = [
{ provider: "local", enabled: true, priority: 0, hasApiKey: false },
{ provider: "openlibrary", enabled: false, priority: 1, hasApiKey: false },
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: false },
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false },
{ provider: "comicvine", enabled: false, priority: 4, hasApiKey: false, requiresCredentials: true },
{ provider: "mangadex", enabled: false, priority: 5, hasApiKey: false }
];
const weekdays = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"];
export function normalizeMetadataSources(config: MetadataSourcesConfigDto | AdminMetadataSourcesConfig): AdminMetadataSourcesConfig {
const received = config.sources as AdminMetadataSourceConfig[];
const merged = defaultMetadataSources.map((source) => ({
...source,
...received.find((item) => item.provider === source.provider)
}));
received.forEach((source) => {
if (!merged.some((item) => item.provider === source.provider)) merged.push(source);
});
const sorted = merged.sort((left, right) => left.priority - right.priority);
const local = sorted.find((source) => source.provider === "local") ?? defaultMetadataSources[0];
const external = sorted.filter((source) => source.provider !== "local");
return {
isbnPriorityEnabled: config.isbnPriorityEnabled,
sources: [
{ ...local, enabled: true, priority: 0 },
...external.map((source, index) => ({ ...source, priority: index + 1 }))
]
};
}
export function metadataSourcesPayload(config: AdminMetadataSourcesConfig): UpdateMetadataSourcesConfigDto {
const sources: Array<{ provider: AdminMetadataProviderId; enabled: boolean; priority: number; apiKey?: string }> = [];
config.sources.forEach((source) => {
if (source.provider === "local") return;
sources.push({
provider: source.provider,
enabled: source.enabled,
priority: sources.length + 1
});
});
return {
isbnPriorityEnabled: config.isbnPriorityEnabled,
sources
} as UpdateMetadataSourcesConfigDto;
}
export function moveSource(sources: AdminMetadataSourceConfig[], provider: AdminMetadataProviderId, direction: -1 | 1): AdminMetadataSourceConfig[] {
const external = sources.filter((source) => source.provider !== "local");
const index = external.findIndex((source) => source.provider === provider);
const nextIndex = index + direction;
if (index < 0 || nextIndex < 0 || nextIndex >= external.length) return sources;
const nextExternal = [...external];
[nextExternal[index], nextExternal[nextIndex]] = [nextExternal[nextIndex], nextExternal[index]];
const local = sources.find((source) => source.provider === "local") ?? { provider: "local", enabled: true, priority: 0, hasApiKey: false };
return [local, ...nextExternal].map((source, priority) => ({ ...source, priority: source.provider === "local" ? 0 : priority }));
}
function sourceStatusText(source: AdminMetadataSourceConfig): string {
return [source.status, source.state, source.health].filter(Boolean).join(" ").toLowerCase();
}
export function providerUiState(source: AdminMetadataSourceConfig): ProviderUiState {
const status = sourceStatusText(source);
if (source.rateLimited || source.quotaLimited || status.includes("limit") || status.includes("quota")) return "limited";
if (source.lastError || status.includes("error") || status.includes("failed")) return "error";
if (source.enabled && (source.requiresCredentials ?? false) && !source.hasApiKey) return "missing-config";
return "configured";
}
export function providerUiStateLabel(source: AdminMetadataSourceConfig): string {
const state = providerUiState(source);
if (state === "missing-config") return "A configurer";
if (state === "limited") return "Limite";
if (state === "error") return "Erreur";
return "Configure";
}
export function providerUiMessage(source: AdminMetadataSourceConfig): string {
const state = providerUiState(source);
if (state === "missing-config") return "Source activee, configuration incomplete.";
if (state === "limited") return "Quota ou limite temporaire atteint. ReadaBook reessaiera plus tard.";
if (state === "error") return "La derniere verification de cette source a echoue.";
return source.enabled ? "Source prete." : "Source desactivee.";
}
export function scheduleSummary(schedule: AutomationScheduleDto, subject: string): string {
if (schedule.frequency === "disabled") return `${subject} desactive.`;
if (schedule.frequency === "daily") return `${subject} tous les jours a ${schedule.time}.`;
return `${subject} chaque ${weekdays[schedule.dayOfWeek]} a ${schedule.time}.`;
}
export const scheduleDays = weekdays.map((label, value) => ({ label, value }));

View File

@ -0,0 +1,559 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import type { CbzPagesDto } from "../api/types";
import { clampReaderPage, orientedContainPageSize, readableViewportSize, readerPositionLabel, zoomReaderSize, type ReaderSize } from "./readerLayout";
import { majorityVisiblePage, type ReaderMode } from "./readerScroll";
import type { ReaderControls } from "./ReaderShell";
type PageCommitStrategy = "immediate" | "queued";
type PreloadedComicImage = {
image: HTMLImageElement;
status: "loading" | "loaded" | "error";
};
type PreloadPriority = "adjacent" | "deep";
type VerticalPreloadScheduler = {
active: number;
generation: number;
timer: number | null;
queuedPages: Set<number>;
queues: Record<PreloadPriority, number[]>;
};
const VERTICAL_ANCHOR_TOLERANCE_PX = 24;
const VERTICAL_ANCHOR_STABLE_FRAMES = 18;
const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180;
const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180;
const VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 3;
const VERTICAL_IMAGE_PRELOAD_DELAY_MS = 100;
const VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET = 90;
const VERTICAL_IMAGE_PRELOAD_DEEP_SPREAD = [0, 1, -1, 2, -2, 3, -3, 4, -4];
const VERTICAL_IMAGE_PRELOAD_PRIORITIES: PreloadPriority[] = ["adjacent", "deep"];
function scheduleReaderFrame(callback: FrameRequestCallback) {
if (typeof requestAnimationFrame === "function") return requestAnimationFrame(callback);
callback(0);
return 0;
}
function cancelReaderFrame(frameId: number) {
if (frameId && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frameId);
}
function createVerticalPreloadScheduler(): VerticalPreloadScheduler {
return {
active: 0,
generation: 0,
timer: null,
queuedPages: new Set<number>(),
queues: {
adjacent: [],
deep: []
}
};
}
function uniqueReaderPages(pages: Array<number | null>, currentPage: number, pageCount: number) {
const seen = new Set<number>();
return pages
.map((candidate) => (candidate ? clampReaderPage(candidate, pageCount) : null))
.filter((candidate): candidate is number => {
if (!candidate || candidate === currentPage || seen.has(candidate)) return false;
seen.add(candidate);
return true;
});
}
function verticalImagePreloadPlan(currentPage: number, pageCount: number) {
const deepCenter = clampReaderPage(currentPage + VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET, pageCount);
return {
adjacent: uniqueReaderPages([currentPage - 1, currentPage + 1, currentPage + 2], currentPage, pageCount),
deep: uniqueReaderPages(
VERTICAL_IMAGE_PRELOAD_DEEP_SPREAD.map((offset) => deepCenter + offset),
currentPage,
pageCount
)
};
}
export function CbzReader({
bookId,
page,
zoom,
mode,
onPageCommit,
onControlsChange
}: {
bookId: number;
page: number;
zoom: number;
mode: ReaderMode;
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
onControlsChange: (controls: ReaderControls) => void;
}) {
const frameRef = useRef<HTMLDivElement>(null);
const previousModeRef = useRef<ReaderMode | null>(null);
const pendingVerticalAnchorRef = useRef(false);
const verticalTrackingReadyRef = useRef(false);
const verticalUserScrollRef = useRef(false);
const verticalAnchorTargetPageRef = useRef<number | null>(null);
const verticalAnchorFrameRef = useRef<number | null>(null);
const verticalInitialSyncFrameRef = useRef<number | null>(null);
const verticalInitialSyncAttemptRef = useRef(0);
const verticalInitialSyncDoneRef = useRef(false);
const verticalAnchorAttemptRef = useRef(0);
const verticalAnchorStableFramesRef = useRef(0);
const preloadedImagesRef = useRef(new Map<number, PreloadedComicImage>());
const preloadSchedulerRef = useRef(createVerticalPreloadScheduler());
const pendingImageSizesRef = useRef<Record<number, ReaderSize>>({});
const imageSizeFlushFrameRef = useRef<number | null>(null);
const [pages, setPages] = useState<CbzPagesDto | null>(null);
const [documentError, setDocumentError] = useState<string>();
const [pageError, setPageError] = useState<string>();
const [documentAttempt, setDocumentAttempt] = useState(0);
const [retryAttempt, setRetryAttempt] = useState(0);
const [viewportSize, setViewportSize] = useState<ReaderSize | null>(null);
const [imageSize, setImageSize] = useState<ReaderSize | null>(null);
const [imageSizes, setImageSizes] = useState<Record<number, ReaderSize>>({});
useEffect(() => {
let alive = true;
setDocumentError(undefined);
setPageError(undefined);
setPages(null);
api
.cbzPages(bookId)
.then((nextPages) => {
if (!alive) return;
setPages(nextPages);
if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount, 1, "immediate");
})
.catch((error) => {
if (!alive) return;
setDocumentError(error instanceof Error && error.message ? `Archive indisponible: ${error.message}` : "Archive indisponible.");
});
return () => {
alive = false;
};
}, [bookId, documentAttempt]);
const pageCount = pages?.pageCount ?? 1;
const currentPage = clampReaderPage(page, pageCount);
const currentName = pages?.pages.find((item) => item.page === currentPage)?.name;
const fittedSize = viewportSize && imageSize ? zoomReaderSize(orientedContainPageSize(viewportSize, imageSize), zoom) : null;
const imageStyle = fittedSize ? { width: `${fittedSize.width}px`, height: `${fittedSize.height}px` } : undefined;
const verticalImageStyle = useCallback(
(pageNumber: number) => {
const size = imageSizes[pageNumber];
if (!viewportSize || !size) return undefined;
const fitted = zoomReaderSize(orientedContainPageSize(viewportSize, size), zoom);
return { width: `${fitted.width}px`, height: `${fitted.height}px` };
},
[imageSizes, viewportSize, zoom]
);
const clearVerticalAnchorFrame = useCallback(() => {
if (verticalAnchorFrameRef.current !== null) cancelAnimationFrame(verticalAnchorFrameRef.current);
verticalAnchorFrameRef.current = null;
}, []);
const clearVerticalInitialSync = useCallback(() => {
if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current);
verticalInitialSyncFrameRef.current = null;
}, []);
const queueImageSize = useCallback((pageNumber: number, size: ReaderSize) => {
pendingImageSizesRef.current[pageNumber] = size;
if (imageSizeFlushFrameRef.current !== null) return;
imageSizeFlushFrameRef.current = scheduleReaderFrame(() => {
imageSizeFlushFrameRef.current = null;
const pending = pendingImageSizesRef.current;
pendingImageSizesRef.current = {};
setImageSizes((current) => {
let changed = false;
const next = { ...current };
for (const [pageKey, nextSize] of Object.entries(pending)) {
const pageNumber = Number(pageKey);
const currentSize = current[pageNumber];
if (currentSize?.width === nextSize.width && currentSize.height === nextSize.height) continue;
next[pageNumber] = nextSize;
changed = true;
}
return changed ? next : current;
});
});
}, []);
const drainVerticalPreloadQueue = useCallback(() => {
const scheduler = preloadSchedulerRef.current;
scheduler.timer = null;
if (typeof Image === "undefined") return;
const nextPage = () => {
for (const priority of VERTICAL_IMAGE_PRELOAD_PRIORITIES) {
const pageNumber = scheduler.queues[priority].shift();
if (pageNumber) {
scheduler.queuedPages.delete(pageNumber);
return pageNumber;
}
}
return null;
};
while (scheduler.active < VERTICAL_IMAGE_PRELOAD_CONCURRENCY) {
const pageNumber = nextPage();
if (!pageNumber) return;
if (preloadedImagesRef.current.has(pageNumber)) continue;
const generation = scheduler.generation;
const image = new Image();
preloadedImagesRef.current.set(pageNumber, { image, status: "loading" });
scheduler.active += 1;
image.decoding = "async";
image.loading = "eager";
image.onload = () => {
if (generation !== scheduler.generation) return;
scheduler.active -= 1;
preloadedImagesRef.current.set(pageNumber, { image, status: "loaded" });
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
queueImageSize(pageNumber, { width: image.naturalWidth, height: image.naturalHeight });
}
drainVerticalPreloadQueue();
};
image.onerror = () => {
if (generation !== scheduler.generation) return;
scheduler.active -= 1;
preloadedImagesRef.current.set(pageNumber, { image, status: "error" });
drainVerticalPreloadQueue();
};
image.src = api.cbzPageUrl(bookId, pageNumber);
}
}, [bookId, queueImageSize]);
const enqueueVerticalPreload = useCallback(
(plan: Record<PreloadPriority, number[]>) => {
const scheduler = preloadSchedulerRef.current;
for (const priority of VERTICAL_IMAGE_PRELOAD_PRIORITIES) {
if (priority === "deep") {
for (const pageNumber of scheduler.queues.deep) scheduler.queuedPages.delete(pageNumber);
scheduler.queues.deep = [];
}
for (const pageNumber of plan[priority]) {
if (preloadedImagesRef.current.has(pageNumber) || scheduler.queuedPages.has(pageNumber)) continue;
scheduler.queuedPages.add(pageNumber);
scheduler.queues[priority].push(pageNumber);
}
}
if (scheduler.timer !== null || scheduler.queuedPages.size === 0) return;
scheduler.timer = window.setTimeout(drainVerticalPreloadQueue, VERTICAL_IMAGE_PRELOAD_DELAY_MS);
},
[drainVerticalPreloadQueue]
);
const commitVisiblePage = useCallback(
(stage: HTMLElement, allowInitialCommit = false) => {
if (!pages || !verticalTrackingReadyRef.current) return;
if (stage.dataset.readerPinchActive === "true") return;
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
const stageRect = stage.getBoundingClientRect();
const visiblePage = majorityVisiblePage(
Array.from(stage.querySelectorAll<HTMLElement>("[data-reader-page]")).map((element) => {
const rect = element.getBoundingClientRect();
return { page: Number(element.dataset.readerPage), top: rect.top, bottom: rect.bottom };
}),
stageRect.top,
stageRect.bottom
);
if (visiblePage && visiblePage !== currentPage) {
if (allowInitialCommit) verticalInitialSyncDoneRef.current = true;
onPageCommit(clampReaderPage(visiblePage, pages.pageCount), pages.pageCount, 1, "queued");
}
},
[currentPage, onPageCommit, pages]
);
const scheduleInitialVisibleSync = useCallback(
(stage: HTMLElement) => {
clearVerticalInitialSync();
verticalInitialSyncAttemptRef.current = 0;
const sync = () => {
verticalInitialSyncFrameRef.current = null;
if (verticalUserScrollRef.current || verticalInitialSyncDoneRef.current) return;
if (verticalTrackingReadyRef.current && stage.scrollHeight > stage.clientHeight && stage.scrollTop > Math.max(32, stage.clientHeight * 0.5)) {
commitVisiblePage(stage, true);
}
verticalInitialSyncAttemptRef.current += 1;
if (verticalInitialSyncAttemptRef.current >= VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS) return;
verticalInitialSyncFrameRef.current = requestAnimationFrame(sync);
};
verticalInitialSyncFrameRef.current = requestAnimationFrame(sync);
},
[clearVerticalInitialSync, commitVisiblePage]
);
const stabilizeVerticalAnchor = useCallback(
(targetPage: number) => {
clearVerticalAnchorFrame();
verticalAnchorAttemptRef.current = 0;
verticalAnchorStableFramesRef.current = 0;
verticalTrackingReadyRef.current = false;
const measure = () => {
const frame = frameRef.current;
const stage = frame?.closest(".reader-stage") as HTMLElement | null;
const target = frame?.querySelector<HTMLElement>(`[data-reader-page="${targetPage}"]`);
if (!stage || !target) return;
target.scrollIntoView({ block: "start" });
verticalAnchorFrameRef.current = requestAnimationFrame(() => {
verticalAnchorFrameRef.current = null;
const stageRect = stage.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const aligned = Math.abs(targetRect.top - stageRect.top) <= VERTICAL_ANCHOR_TOLERANCE_PX;
const cannotScrollFurther = stage.scrollTop + stage.clientHeight >= stage.scrollHeight - 2;
verticalAnchorStableFramesRef.current = aligned || cannotScrollFurther ? verticalAnchorStableFramesRef.current + 1 : 0;
if (verticalAnchorStableFramesRef.current >= VERTICAL_ANCHOR_STABLE_FRAMES) {
verticalTrackingReadyRef.current = true;
commitVisiblePage(stage, true);
scheduleInitialVisibleSync(stage);
return;
}
verticalAnchorAttemptRef.current += 1;
if (verticalAnchorAttemptRef.current >= VERTICAL_ANCHOR_MAX_ATTEMPTS) return;
verticalAnchorFrameRef.current = requestAnimationFrame(measure);
});
};
verticalAnchorFrameRef.current = requestAnimationFrame(measure);
},
[clearVerticalAnchorFrame, commitVisiblePage, scheduleInitialVisibleSync]
);
const go = useCallback(
(nextPage: number) => {
if (!pages) return;
const target = clampReaderPage(nextPage, pages.pageCount);
setPageError(undefined);
if (mode === "vertical") {
verticalUserScrollRef.current = false;
frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" });
}
onPageCommit(target, pages.pageCount, 1, "immediate");
},
[mode, onPageCommit, pages]
);
useEffect(() => {
setPageError(undefined);
setImageSize(null);
}, [bookId, currentPage, retryAttempt]);
useEffect(() => {
setImageSizes({});
pendingImageSizesRef.current = {};
preloadedImagesRef.current.clear();
const scheduler = preloadSchedulerRef.current;
if (scheduler.timer !== null) window.clearTimeout(scheduler.timer);
preloadSchedulerRef.current = createVerticalPreloadScheduler();
preloadSchedulerRef.current.generation = scheduler.generation + 1;
}, [bookId, retryAttempt]);
useEffect(() => {
if (mode !== "vertical" || !pages || typeof Image === "undefined" || !verticalUserScrollRef.current) return;
enqueueVerticalPreload(verticalImagePreloadPlan(currentPage, pages.pageCount));
}, [currentPage, enqueueVerticalPreload, mode, pages, retryAttempt]);
useEffect(() => {
const frame = frameRef.current;
const stage = frame?.closest(".reader-stage") as HTMLElement | null;
const observedElement = stage ?? frame;
if (!observedElement) return;
const updateSize = () => {
const rect = observedElement.getBoundingClientRect();
const nextSize = readableViewportSize({ width: rect.width, height: rect.height });
if (nextSize) setViewportSize(nextSize);
};
updateSize();
const frameId = requestAnimationFrame(updateSize);
const observer = new ResizeObserver(updateSize);
observer.observe(observedElement);
return () => {
cancelAnimationFrame(frameId);
observer.disconnect();
};
}, []);
useEffect(() => {
onControlsChange({
canPrevious: Boolean(pages) && !documentError && !pageError && currentPage > 1,
canNext: Boolean(pages) && !documentError && !pageError && currentPage < pageCount,
positionLabel: pages ? readerPositionLabel(currentPage, pageCount) : "Chargement",
onPrevious: () => go(currentPage - 1),
onNext: () => go(currentPage + 1)
});
}, [currentPage, documentError, go, onControlsChange, pageCount, pageError, pages]);
useEffect(() => {
if (mode !== "vertical") {
previousModeRef.current = mode;
pendingVerticalAnchorRef.current = false;
verticalTrackingReadyRef.current = false;
verticalUserScrollRef.current = false;
verticalAnchorTargetPageRef.current = null;
verticalInitialSyncDoneRef.current = false;
clearVerticalAnchorFrame();
clearVerticalInitialSync();
return;
}
const enteringVertical = previousModeRef.current !== "vertical";
if (enteringVertical || (!verticalUserScrollRef.current && verticalAnchorTargetPageRef.current !== currentPage)) {
pendingVerticalAnchorRef.current = true;
verticalTrackingReadyRef.current = false;
verticalUserScrollRef.current = false;
if (enteringVertical) verticalInitialSyncDoneRef.current = false;
clearVerticalAnchorFrame();
clearVerticalInitialSync();
}
if (pendingVerticalAnchorRef.current) {
const target = frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`);
if (!target) {
previousModeRef.current = mode;
return;
}
pendingVerticalAnchorRef.current = false;
verticalAnchorTargetPageRef.current = currentPage;
stabilizeVerticalAnchor(currentPage);
}
previousModeRef.current = mode;
}, [clearVerticalAnchorFrame, clearVerticalInitialSync, currentPage, mode, pages, stabilizeVerticalAnchor]);
useEffect(() => {
if (mode !== "vertical" || !pages) return;
const stage = frameRef.current?.closest(".reader-stage") as HTMLElement | null;
if (!stage) return;
let frameId = 0;
const markUserScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
verticalUserScrollRef.current = true;
};
const markUserScrollKey = (event: KeyboardEvent) => {
if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"].includes(event.key)) markUserScroll();
};
const keyTarget = typeof window === "undefined" ? null : window;
const updateVisiblePage = () => {
frameId = 0;
commitVisiblePage(stage);
};
const onScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true;
if (frameId) return;
frameId = requestAnimationFrame(updateVisiblePage);
};
stage.addEventListener("wheel", markUserScroll, { passive: true });
stage.addEventListener("touchmove", markUserScroll, { passive: true });
stage.addEventListener("pointerdown", markUserScroll, { passive: true });
keyTarget?.addEventListener("keydown", markUserScrollKey);
stage.addEventListener("scroll", onScroll, { passive: true });
scheduleInitialVisibleSync(stage);
updateVisiblePage();
return () => {
if (frameId) cancelAnimationFrame(frameId);
stage.removeEventListener("wheel", markUserScroll);
stage.removeEventListener("touchmove", markUserScroll);
stage.removeEventListener("pointerdown", markUserScroll);
keyTarget?.removeEventListener("keydown", markUserScrollKey);
stage.removeEventListener("scroll", onScroll);
};
}, [commitVisiblePage, mode, pages, scheduleInitialVisibleSync]);
useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]);
useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]);
useEffect(
() => () => {
if (imageSizeFlushFrameRef.current !== null) cancelReaderFrame(imageSizeFlushFrameRef.current);
},
[]
);
if (documentError) {
return (
<div className="cbz-reader" ref={frameRef}>
<div className="reader-fallback reader-fallback-error">
<span>{documentError}</span>
<button className="ghost-button" onClick={() => setDocumentAttempt((attempt) => attempt + 1)}>
Reessayer
</button>
</div>
</div>
);
}
if (!pages) {
return (
<div className="cbz-reader" ref={frameRef}>
<div className="reader-fallback">
<span>Chargement de l'archive</span>
</div>
</div>
);
}
if (pageError) {
return (
<div className="cbz-reader" ref={frameRef}>
<div className="reader-fallback reader-fallback-error">
<span>{pageError}</span>
<button className="ghost-button" onClick={() => setRetryAttempt((attempt) => attempt + 1)}>
Reessayer
</button>
</div>
</div>
);
}
return (
<div className={`cbz-reader${mode === "vertical" ? " cbz-reader-vertical" : ""}`} ref={frameRef}>
{mode === "vertical" ? (
<div className="reader-vertical-stack">
{pages.pages.map((item) => (
<figure className="comic-page-frame comic-page-frame-vertical" data-reader-page={item.page} key={`${item.page}-${retryAttempt}`}>
{!imageSizes[item.page] && (
<div className="reader-fallback">
<span>Page {item.page}</span>
</div>
)}
<img
src={api.cbzPageUrl(bookId, item.page)}
alt={item.name}
fetchPriority={item.page === currentPage ? "high" : "auto"}
loading={item.page === currentPage ? "eager" : "lazy"}
style={verticalImageStyle(item.page)}
onLoad={(event) => {
const { naturalWidth, naturalHeight } = event.currentTarget;
preloadedImagesRef.current.set(item.page, { image: event.currentTarget, status: "loaded" });
queueImageSize(item.page, { width: naturalWidth, height: naturalHeight });
}}
onError={() => setPageError(`Page ${item.page} indisponible.`)}
/>
</figure>
))}
</div>
) : (
<figure className="comic-page-frame" data-reader-page={currentPage}>
{!imageSize && (
<div className="reader-fallback">
<span>Page {currentPage}</span>
</div>
)}
<img
key={`${currentPage}-${retryAttempt}`}
src={api.cbzPageUrl(bookId, currentPage)}
alt={currentName ?? `Page ${currentPage}`}
style={imageStyle}
hidden={!imageSize}
onLoad={(event) => {
setImageSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight });
}}
onError={() => setPageError(`Page ${currentPage} indisponible.`)}
/>
</figure>
)}
</div>
);
}

View File

@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hookState = vi.hoisted(() => ({
stateIndex: 0,
states: [] as unknown[],
updates: [] as unknown[][]
}));
vi.mock("react", async () => {
const actual = await vi.importActual<typeof import("react")>("react");
return {
...actual,
useCallback: (callback: unknown) => callback,
useEffect: () => undefined,
useRef: (current: unknown) => ({ current }),
useState: (initial: unknown) => {
const index = hookState.stateIndex;
hookState.stateIndex += 1;
hookState.updates[index] = [];
return [
hookState.states[index] ?? initial,
(next: unknown) => {
hookState.updates[index].push(next);
}
];
}
};
});
vi.mock("../api/client", () => ({
api: {
cbzPages: vi.fn().mockResolvedValue({ bookId: 2, pageCount: 1, pages: [{ page: 1, name: "page-1.jpg" }] }),
cbzPageUrl: (bookId: number, page: number) => `/books/${bookId}/pages/${page}`
}
}));
import { CbzReader } from "./CbzReader";
type ElementLike = {
type: unknown;
props?: Record<string, unknown> & { children?: unknown };
};
function isElementLike(value: unknown): value is ElementLike {
return Boolean(value && typeof value === "object" && "type" in value);
}
function findElementsByType(node: unknown, type: string): ElementLike[] {
if (Array.isArray(node)) return node.flatMap((child) => findElementsByType(child, type));
if (!isElementLike(node)) return [];
const matches = node.type === type ? [node] : [];
return [...matches, ...findElementsByType(node.props?.children, type)];
}
describe("CbzReader vertical image load", () => {
beforeEach(() => {
hookState.stateIndex = 0;
hookState.updates = [];
hookState.states = [
{ bookId: 2, pageCount: 1, pages: [{ page: 1, name: "page-1.jpg" }] },
undefined,
undefined,
0,
0,
{ width: 800, height: 1200 },
null,
{}
];
});
it("records vertical image dimensions before React clears the load event target", () => {
const tree = CbzReader({
bookId: 2,
page: 1,
zoom: 100,
mode: "vertical",
onPageCommit: vi.fn(),
onControlsChange: vi.fn()
});
const image = findElementsByType(tree, "img")[0];
const onLoad = image.props?.onLoad as (event: { currentTarget: { naturalWidth: number; naturalHeight: number } | null }) => void;
const event: { currentTarget: { naturalWidth: number; naturalHeight: number } | null } = { currentTarget: { naturalWidth: 480, naturalHeight: 960 } };
onLoad(event);
event.currentTarget = null;
let nextSizes: unknown;
expect(() => {
nextSizes = (hookState.updates[7][0] as (current: Record<number, unknown>) => unknown)({});
}).not.toThrow();
expect(nextSizes).toEqual({ 1: { width: 480, height: 960 } });
});
});

View File

@ -1,37 +1,156 @@
import { useEffect, useRef, useState } from "react";
import { ReaderError, readerErrorMessage } from "./ReaderError";
import type { ReaderControls } from "./ReaderShell";
import type { ReaderMode } from "../api/types";
type FoliateModule = {
EPUB?: unknown;
default?: unknown;
type FoliateLocation = {
cfi?: string;
fraction?: number;
current?: number;
total?: number;
};
export function EpubReader({ url, locator, onLocatorChange }: { url: string; locator?: string; onLocatorChange: (locator: string, percent: number) => void }) {
type FoliateView = HTMLElement & {
open(input: File | Blob | string): Promise<void>;
close(): void;
goLeft(): Promise<void>;
goRight(): Promise<void>;
goTo(target: string): Promise<unknown>;
next(): Promise<void>;
lastLocation?: FoliateLocation;
};
export function epubFileName(url: string): string {
try {
const base = globalThis.location?.href ?? "http://readabook.local/";
const pathname = new URL(url, base).pathname;
const name = pathname.split("/").filter(Boolean).at(-1);
return name && name.includes(".") ? name : "book.epub";
} catch {
return "book.epub";
}
}
function locationPercent(location: FoliateLocation): number {
if (typeof location.fraction === "number") return Math.max(0, Math.min(100, location.fraction * 100));
if (typeof location.current === "number" && typeof location.total === "number" && location.total > 0) {
return Math.max(0, Math.min(100, (location.current / location.total) * 100));
}
return 1;
}
export function EpubReader({
url,
locator,
backHref,
mode,
onLocatorChange,
onControlsChange
}: {
url: string;
locator?: string;
backHref: string;
mode: ReaderMode;
onLocatorChange: (locator: string, percent: number) => void;
onControlsChange: (controls: ReaderControls) => void;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState("Ouverture EPUB");
const viewRef = useRef<FoliateView | null>(null);
const locatorRef = useRef(locator);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();
const [attempt, setAttempt] = useState(0);
useEffect(() => {
onControlsChange({
canPrevious: !loading && !error,
canNext: !loading && !error,
positionLabel: loading ? "Ouverture EPUB" : "Lecture integree",
onPrevious: () => void viewRef.current?.goLeft(),
onNext: () => void viewRef.current?.goRight()
});
}, [error, loading, onControlsChange]);
useEffect(() => {
locatorRef.current = locator;
}, [locator]);
useEffect(() => {
let cancelled = false;
let view: FoliateView | null = null;
async function mount() {
try {
const module = (await import("foliate-js/epub.js")) as FoliateModule;
setLoading(true);
setError(undefined);
await import("foliate-js/view.js");
if (cancelled || !hostRef.current) return;
hostRef.current.dataset.engine = module.EPUB || module.default ? "foliate-js" : "fallback";
setStatus("EPUB pret");
onLocatorChange(locator ?? "epub:start", locator ? 35 : 1);
} catch {
setStatus("Apercu EPUB indisponible dans ce navigateur");
const response = await fetch(url, { credentials: "include" });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
const blob = await response.blob();
if (cancelled || !hostRef.current) return;
view = document.createElement("foliate-view") as FoliateView;
view.classList.add("epub-view");
view.addEventListener("relocate", (event) => {
const location = (event as CustomEvent<FoliateLocation>).detail;
if (location?.cfi) onLocatorChange(location.cfi, locationPercent(location));
});
hostRef.current.replaceChildren(view);
viewRef.current = view;
const file = new File([blob], epubFileName(url), { type: blob.type || "application/epub+zip" });
await view.open(file);
if (cancelled) return;
if (locatorRef.current) await view.goTo(locatorRef.current);
else await view.next();
setLoading(false);
} catch (mountError) {
if (!cancelled) {
setError(readerErrorMessage(mountError, "EPUB indisponible"));
setLoading(false);
}
}
}
mount();
void mount();
return () => {
cancelled = true;
view?.close?.();
view?.remove();
if (viewRef.current === view) viewRef.current = null;
};
}, [locator, onLocatorChange, url]);
}, [attempt, onLocatorChange, url]);
useEffect(() => {
viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical");
viewRef.current?.classList.toggle("epub-view-horizontal", mode === "horizontal");
}, [mode]);
if (error) {
return (
<div className="epub-reader">
<ReaderError
title="Lecture EPUB indisponible"
detail="ReadaBook n'a pas pu ouvrir ce fichier dans le lecteur web."
technicalDetail={error}
downloadUrl={url}
backHref={backHref}
onRetry={() => setAttempt((value) => value + 1)}
/>
</div>
);
}
return (
<div className="epub-reader" ref={hostRef}>
<iframe title="EPUB" src={url} />
<div className="reader-fallback">{status}</div>
<div className={`epub-reader epub-reader-${mode}`}>
{loading && (
<div className="reader-fallback">
<span>Ouverture EPUB</span>
</div>
)}
<div className="epub-host" ref={hostRef} />
</div>
);
}

View File

@ -1,56 +1,630 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import * as pdfjs from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.mjs?url";
import { ReaderError } from "./ReaderError";
import { pdfDocumentOptions } from "./pdfDocumentOptions";
import { classifyPdfCanvas, pdfCanvasVisible, pdfRenderScale, pdfSentinelBackground, type PdfRenderResult } from "./pdfRender";
import { configurePdfWorker } from "./pdfWorker";
import { clampReaderPage, readableViewportSize, readerPositionLabel, readerZoomFactor, type ReaderSize } from "./readerLayout";
import { isPageInRenderWindow, majorityVisiblePage, type ReaderMode } from "./readerScroll";
import type { ReaderControls } from "./ReaderShell";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
configurePdfWorker(pdfjs);
export function PdfReader({ url, page, onPageChange }: { url: string; page: number; onPageChange: (page: number, pages: number) => void }) {
type PageCommitStrategy = "immediate" | "queued";
type PdfReaderProps = {
url: string;
page: number;
backHref: string;
zoom: number;
mode: ReaderMode;
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
onControlsChange: (controls: ReaderControls) => void;
};
type PdfFailure = {
detail: string;
technicalDetail: string;
};
type PdfPageStatus = "idle" | "loading" | PdfRenderResult | "render-failed";
const VERTICAL_ANCHOR_TOLERANCE_PX = 24;
const VERTICAL_ANCHOR_STABLE_FRAMES = 18;
const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180;
const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180;
function pdfTechnicalMessage(error: unknown) {
if (error instanceof Error && error.message.trim()) return `${error.name}: ${error.message}`;
if (typeof error === "string" && error.trim()) return error;
return "Erreur inconnue.";
}
function pdfFailure(source: string, detail: string, context: Record<string, unknown>, error?: unknown): PdfFailure {
return {
detail,
technicalDetail: JSON.stringify(
{
source,
...context,
error: error === undefined ? undefined : pdfTechnicalMessage(error)
},
null,
2
)
};
}
function isPdfTransitionError(error: unknown) {
return error instanceof Error && (error.message.includes("worker is being destroyed") || error.message.includes("Rendering cancelled"));
}
function scrollContainerFor(element: HTMLElement | null): HTMLElement | null {
return element?.closest(".reader-stage") as HTMLElement | null;
}
function afterNextPaint() {
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
function PdfPageCanvas({
documentProxy,
pageNumber,
viewportSize,
zoom,
active
}: {
documentProxy: pdfjs.PDFDocumentProxy;
pageNumber: number;
viewportSize: ReaderSize | null;
zoom: number;
active: boolean;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [pages, setPages] = useState(1);
const [error, setError] = useState<string>();
const [loading, setLoading] = useState(false);
const [rendered, setRendered] = useState(false);
const [pageStatus, setPageStatus] = useState<PdfPageStatus>("idle");
const [pageError, setPageError] = useState<string>();
const showCanvas = active && pdfCanvasVisible(rendered, Boolean(pageError));
useEffect(() => {
if (!active || !viewportSize) return;
const readerViewport = viewportSize;
let cancelled = false;
async function render() {
let renderTask: pdfjs.RenderTask | undefined;
setLoading(true);
setRendered(false);
setPageStatus("loading");
setPageError(undefined);
async function renderPage() {
try {
const loadingTask = pdfjs.getDocument({ url, withCredentials: true });
const document = await loadingTask.promise;
const pdfPage = await documentProxy.getPage(pageNumber);
if (cancelled) return;
setPages(document.numPages);
const pdfPage = await document.getPage(Math.max(1, Math.min(page, document.numPages)));
const canvas = canvasRef.current;
if (!canvas) return;
const viewport = pdfPage.getViewport({ scale: Math.min(1.6, window.devicePixelRatio || 1) });
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext("2d");
if (!context) return;
await pdfPage.render({ canvas, canvasContext: context, viewport }).promise;
onPageChange(Math.max(1, Math.min(page, document.numPages)), document.numPages);
} catch (renderError) {
setError(renderError instanceof Error ? renderError.message : "PDF indisponible");
const baseViewport = pdfPage.getViewport({ scale: 1 });
const fitScale = pdfRenderScale(readerViewport, { width: baseViewport.width, height: baseViewport.height });
const pixelRatio = Math.min(2, window.devicePixelRatio || 1);
const viewport = pdfPage.getViewport({ scale: Math.max(0.25, fitScale * readerZoomFactor(zoom)) * pixelRatio });
const cssWidth = Math.floor(viewport.width / pixelRatio);
const cssHeight = Math.floor(viewport.height / pixelRatio);
canvas.width = Math.max(1, Math.floor(viewport.width));
canvas.height = Math.max(1, Math.floor(viewport.height));
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
const context = canvas.getContext("2d", { alpha: false });
if (!context) throw new Error("PDF canvas context unavailable");
context.fillStyle = pdfSentinelBackground();
context.fillRect(0, 0, canvas.width, canvas.height);
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
await renderTask.promise;
await afterNextPaint();
if (cancelled) return;
setPageStatus(classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height)));
setRendered(true);
setLoading(false);
} catch (error) {
if (cancelled || isPdfTransitionError(error)) return;
setPageStatus("render-failed");
setLoading(false);
setPageError(`Page ${pageNumber} indisponible.`);
}
}
render();
void renderPage();
return () => {
cancelled = true;
renderTask?.cancel();
};
}, [url, page, onPageChange]);
}, [active, documentProxy, pageNumber, viewportSize, zoom]);
return (
<div className="pdf-reader">
{error ? <div className="reader-fallback">{error}</div> : <canvas ref={canvasRef} />}
<div className="reader-stepper">
<button className="ghost-button" onClick={() => onPageChange(Math.max(1, page - 1), pages)}>
Precedent
</button>
<span>
{page} / {pages}
</span>
<button className="ghost-button" onClick={() => onPageChange(Math.min(pages, page + 1), pages)}>
Suivant
</button>
</div>
<div className="pdf-page-frame pdf-page-frame-vertical" data-reader-page={pageNumber}>
{(!active || !viewportSize || loading || !rendered || pageError) && (
<div className={`reader-fallback${pageError ? " reader-fallback-error" : ""}`}>
<span>{pageError ?? `Page ${pageNumber}`}</span>
</div>
)}
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
</div>
);
}
export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onControlsChange }: PdfReaderProps) {
const frameRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const previousModeRef = useRef<ReaderMode | null>(null);
const pendingVerticalAnchorRef = useRef(false);
const verticalTrackingReadyRef = useRef(false);
const verticalUserScrollRef = useRef(false);
const verticalAnchorTargetPageRef = useRef<number | null>(null);
const verticalAnchorFrameRef = useRef<number | null>(null);
const verticalInitialSyncFrameRef = useRef<number | null>(null);
const verticalInitialSyncAttemptRef = useRef(0);
const verticalInitialSyncDoneRef = useRef(false);
const verticalAnchorAttemptRef = useRef(0);
const verticalAnchorStableFramesRef = useRef(0);
const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
const [pages, setPages] = useState(1);
const [documentError, setDocumentError] = useState<PdfFailure>();
const [pageError, setPageError] = useState<PdfFailure>();
const [loadingDocument, setLoadingDocument] = useState(true);
const [loadingPage, setLoadingPage] = useState(false);
const [pageRendered, setPageRendered] = useState(false);
const [pageStatus, setPageStatus] = useState<PdfPageStatus>("idle");
const [documentAttempt, setDocumentAttempt] = useState(0);
const [pageAttempt, setPageAttempt] = useState(0);
const [viewportSize, setViewportSize] = useState<ReaderSize | null>(null);
const currentPage = clampReaderPage(page, pages);
const showCanvas = pdfCanvasVisible(pageRendered, Boolean(pageError));
const clearVerticalAnchorFrame = useCallback(() => {
if (verticalAnchorFrameRef.current !== null) cancelAnimationFrame(verticalAnchorFrameRef.current);
verticalAnchorFrameRef.current = null;
}, []);
const clearVerticalInitialSync = useCallback(() => {
if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current);
verticalInitialSyncFrameRef.current = null;
}, []);
const commitVisiblePage = useCallback(
(stage: HTMLElement, allowInitialCommit = false) => {
if (!verticalTrackingReadyRef.current) return;
if (stage.dataset.readerPinchActive === "true") return;
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
const stageRect = stage.getBoundingClientRect();
const visiblePage = majorityVisiblePage(
Array.from(stage.querySelectorAll<HTMLElement>("[data-reader-page]")).map((element) => {
const rect = element.getBoundingClientRect();
return { page: Number(element.dataset.readerPage), top: rect.top, bottom: rect.bottom };
}),
stageRect.top,
stageRect.bottom
);
if (visiblePage && visiblePage !== currentPage) {
if (allowInitialCommit) verticalInitialSyncDoneRef.current = true;
onPageCommit(clampReaderPage(visiblePage, pages), pages, 1, "queued");
}
},
[currentPage, onPageCommit, pages]
);
const scheduleInitialVisibleSync = useCallback(
(stage: HTMLElement) => {
clearVerticalInitialSync();
verticalInitialSyncAttemptRef.current = 0;
const sync = () => {
verticalInitialSyncFrameRef.current = null;
if (verticalUserScrollRef.current || verticalInitialSyncDoneRef.current) return;
if (verticalTrackingReadyRef.current && stage.scrollHeight > stage.clientHeight && stage.scrollTop > Math.max(32, stage.clientHeight * 0.5)) {
commitVisiblePage(stage, true);
}
verticalInitialSyncAttemptRef.current += 1;
if (verticalInitialSyncAttemptRef.current >= VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS) return;
verticalInitialSyncFrameRef.current = requestAnimationFrame(sync);
};
verticalInitialSyncFrameRef.current = requestAnimationFrame(sync);
},
[clearVerticalInitialSync, commitVisiblePage]
);
const stabilizeVerticalAnchor = useCallback(
(targetPage: number) => {
clearVerticalAnchorFrame();
verticalAnchorAttemptRef.current = 0;
verticalAnchorStableFramesRef.current = 0;
verticalTrackingReadyRef.current = false;
const measure = () => {
const frame = frameRef.current;
const stage = scrollContainerFor(frame);
const target = frame?.querySelector<HTMLElement>(`[data-reader-page="${targetPage}"]`);
if (!stage || !target) return;
target.scrollIntoView({ block: "start" });
verticalAnchorFrameRef.current = requestAnimationFrame(() => {
verticalAnchorFrameRef.current = null;
const stageRect = stage.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const aligned = Math.abs(targetRect.top - stageRect.top) <= VERTICAL_ANCHOR_TOLERANCE_PX;
const cannotScrollFurther = stage.scrollTop + stage.clientHeight >= stage.scrollHeight - 2;
verticalAnchorStableFramesRef.current = aligned || cannotScrollFurther ? verticalAnchorStableFramesRef.current + 1 : 0;
if (verticalAnchorStableFramesRef.current >= VERTICAL_ANCHOR_STABLE_FRAMES) {
verticalTrackingReadyRef.current = true;
commitVisiblePage(stage, true);
scheduleInitialVisibleSync(stage);
return;
}
verticalAnchorAttemptRef.current += 1;
if (verticalAnchorAttemptRef.current >= VERTICAL_ANCHOR_MAX_ATTEMPTS) return;
verticalAnchorFrameRef.current = requestAnimationFrame(measure);
});
};
verticalAnchorFrameRef.current = requestAnimationFrame(measure);
},
[clearVerticalAnchorFrame, commitVisiblePage, scheduleInitialVisibleSync]
);
const go = useCallback(
(nextPage: number) => {
const target = clampReaderPage(nextPage, pages);
setPageError(undefined);
if (mode === "vertical") {
verticalUserScrollRef.current = false;
frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" });
}
onPageCommit(target, pages, 1, "immediate");
},
[mode, onPageCommit, pages]
);
useEffect(() => {
onControlsChange({
canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1,
canNext: !loadingDocument && !documentError && !pageError && currentPage < pages,
positionLabel: loadingDocument ? "Chargement" : readerPositionLabel(currentPage, pages),
onPrevious: () => go(currentPage - 1),
onNext: () => go(currentPage + 1)
});
}, [currentPage, documentError, go, loadingDocument, onControlsChange, pageError, pages]);
useEffect(() => {
if (mode !== "vertical") {
previousModeRef.current = mode;
pendingVerticalAnchorRef.current = false;
verticalTrackingReadyRef.current = false;
verticalUserScrollRef.current = false;
verticalAnchorTargetPageRef.current = null;
verticalInitialSyncDoneRef.current = false;
clearVerticalAnchorFrame();
clearVerticalInitialSync();
return;
}
const enteringVertical = previousModeRef.current !== "vertical";
if (enteringVertical || (!verticalUserScrollRef.current && verticalAnchorTargetPageRef.current !== currentPage)) {
pendingVerticalAnchorRef.current = true;
verticalTrackingReadyRef.current = false;
verticalUserScrollRef.current = false;
if (enteringVertical) verticalInitialSyncDoneRef.current = false;
clearVerticalAnchorFrame();
clearVerticalInitialSync();
}
if (pendingVerticalAnchorRef.current) {
const target = frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`);
if (!target) {
previousModeRef.current = mode;
return;
}
pendingVerticalAnchorRef.current = false;
verticalAnchorTargetPageRef.current = currentPage;
stabilizeVerticalAnchor(currentPage);
}
previousModeRef.current = mode;
}, [clearVerticalAnchorFrame, clearVerticalInitialSync, currentPage, documentProxy, mode, stabilizeVerticalAnchor]);
useEffect(() => {
if (mode !== "vertical") return;
const stage = scrollContainerFor(frameRef.current);
if (!stage) return;
let frameId = 0;
const markUserScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
verticalUserScrollRef.current = true;
};
const markUserScrollKey = (event: KeyboardEvent) => {
if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"].includes(event.key)) markUserScroll();
};
const keyTarget = typeof window === "undefined" ? null : window;
const updateVisiblePage = () => {
frameId = 0;
commitVisiblePage(stage);
};
const onScroll = () => {
if (stage.dataset.readerPinchActive === "true") return;
if (frameId) return;
frameId = requestAnimationFrame(updateVisiblePage);
};
stage.addEventListener("wheel", markUserScroll, { passive: true });
stage.addEventListener("touchmove", markUserScroll, { passive: true });
stage.addEventListener("pointerdown", markUserScroll, { passive: true });
keyTarget?.addEventListener("keydown", markUserScrollKey);
stage.addEventListener("scroll", onScroll, { passive: true });
scheduleInitialVisibleSync(stage);
updateVisiblePage();
return () => {
if (frameId) cancelAnimationFrame(frameId);
stage.removeEventListener("wheel", markUserScroll);
stage.removeEventListener("touchmove", markUserScroll);
stage.removeEventListener("pointerdown", markUserScroll);
keyTarget?.removeEventListener("keydown", markUserScrollKey);
stage.removeEventListener("scroll", onScroll);
};
}, [commitVisiblePage, mode, pages, scheduleInitialVisibleSync]);
useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]);
useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]);
useEffect(() => {
const frame = frameRef.current;
const stage = scrollContainerFor(frame);
const observedElement = stage ?? frame;
if (!observedElement) return;
const updateSize = () => {
const rect = observedElement.getBoundingClientRect();
const nextSize = readableViewportSize({ width: rect.width, height: rect.height });
if (nextSize) setViewportSize(nextSize);
};
updateSize();
const frameId = requestAnimationFrame(updateSize);
const observer = new ResizeObserver(updateSize);
observer.observe(observedElement);
return () => {
cancelAnimationFrame(frameId);
observer.disconnect();
};
}, []);
useEffect(() => {
let cancelled = false;
let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
setLoadingDocument(true);
setDocumentError(undefined);
setPageError(undefined);
setPageRendered(false);
setPageStatus("idle");
setDocumentProxy(null);
async function loadDocument() {
try {
try {
new URL(url, window.location.href);
} catch (error) {
throw pdfFailure("uri", "PDF: URI de fichier invalide.", { url }, error);
}
configurePdfWorker(pdfjs);
loadingTask = pdfjs.getDocument(pdfDocumentOptions({ url, withCredentials: true }, "browser"));
const loadedDocument = await loadingTask.promise;
if (cancelled) {
await loadingTask.destroy();
return;
}
setPages(loadedDocument.numPages);
setDocumentProxy(loadedDocument);
setLoadingDocument(false);
} catch (error) {
if (cancelled && isPdfTransitionError(error)) return;
if (!cancelled) {
setDocumentError(
typeof error === "object" && error && "detail" in error && "technicalDetail" in error
? (error as PdfFailure)
: pdfFailure(
"getDocument",
"PDF: getDocument() a échoué.",
{
url,
resolvedUrl: (() => {
try {
return new URL(url, window.location.href).toString();
} catch {
return null;
}
})(),
workerSrc: pdfjs.GlobalWorkerOptions.workerSrc,
hasWorkerPort: Boolean(pdfjs.GlobalWorkerOptions.workerPort)
},
error
)
);
setLoadingDocument(false);
}
}
}
void loadDocument();
return () => {
cancelled = true;
void loadingTask?.destroy();
};
}, [documentAttempt, url]);
useEffect(() => {
if (mode === "vertical") {
setPageError(undefined);
return;
}
if (!documentProxy || !viewportSize) return;
const loadedDocument = documentProxy;
const readerViewport = viewportSize;
let cancelled = false;
let renderTask: pdfjs.RenderTask | undefined;
setLoadingPage(true);
setPageError(undefined);
setPageRendered(false);
setPageStatus("loading");
async function renderPage() {
try {
let pdfPage: pdfjs.PDFPageProxy;
try {
pdfPage = await loadedDocument.getPage(currentPage);
} catch (error) {
throw pdfFailure("getPage", `PDF: getPage(${currentPage}) a échoué.`, { pageNumber: currentPage }, error);
}
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) {
throw pdfFailure("react-canvas-ref", `PDF: canvas absent pour la page ${currentPage}.`, { pageNumber: currentPage });
}
const baseViewport = pdfPage.getViewport({ scale: 1 });
const fitScale = pdfRenderScale(readerViewport, { width: baseViewport.width, height: baseViewport.height });
const pixelRatio = Math.min(2, window.devicePixelRatio || 1);
const zoomedScale = fitScale * readerZoomFactor(zoom);
const renderScale = Math.max(0.25, zoomedScale) * pixelRatio;
const viewport = pdfPage.getViewport({ scale: renderScale });
const cssWidth = Math.floor(viewport.width / pixelRatio);
const cssHeight = Math.floor(viewport.height / pixelRatio);
canvas.width = Math.max(1, Math.floor(viewport.width));
canvas.height = Math.max(1, Math.floor(viewport.height));
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
const context = canvas.getContext("2d", { alpha: false });
if (!context) {
throw pdfFailure("canvas-context", `PDF: contexte canvas 2D indisponible pour la page ${currentPage}.`, {
pageNumber: currentPage,
canvasWidth: canvas.width,
canvasHeight: canvas.height
});
}
if (canvas.width <= 0 || canvas.height <= 0 || cssWidth <= 0 || cssHeight <= 0) {
throw pdfFailure("canvas-size", `PDF: dimensions canvas invalides pour la page ${currentPage}.`, {
pageNumber: currentPage,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
cssWidth,
cssHeight,
readerViewport
});
}
context.fillStyle = pdfSentinelBackground();
context.fillRect(0, 0, canvas.width, canvas.height);
try {
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
await renderTask.promise;
} catch (error) {
throw pdfFailure(
"render-failed",
`PDF: render() a échoué pour la page ${currentPage}.`,
{
result: "render-failed",
pageNumber: currentPage,
baseViewport: { width: baseViewport.width, height: baseViewport.height },
renderViewport: { width: viewport.width, height: viewport.height },
canvasWidth: canvas.width,
canvasHeight: canvas.height,
cssWidth,
cssHeight,
readerViewport
},
error
);
}
await afterNextPaint();
if (cancelled) return;
const renderResult = classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height));
if (renderResult === "blank-detected") {
setPageStatus("blank-detected");
setPageRendered(true);
setLoadingPage(false);
return;
}
if (!cancelled) {
setPageStatus("rendered-ok");
setPageRendered(true);
setLoadingPage(false);
}
} catch (error) {
if (cancelled || isPdfTransitionError(error)) return;
setPageRendered(false);
setPageStatus("render-failed");
setLoadingPage(false);
setPageError(
typeof error === "object" && error && "detail" in error && "technicalDetail" in error
? (error as PdfFailure)
: pdfFailure("render-unclassified", `PDF: erreur non classée pendant le rendu de la page ${currentPage}.`, { pageNumber: currentPage }, error)
);
}
}
void renderPage();
return () => {
cancelled = true;
renderTask?.cancel();
};
}, [currentPage, documentProxy, mode, pageAttempt, viewportSize, zoom]);
if (documentError) {
return (
<div className="pdf-reader" ref={frameRef}>
<ReaderError
title="Lecture PDF indisponible"
detail={documentError.detail}
technicalDetail={documentError.technicalDetail}
downloadUrl={url}
backHref={backHref}
onRetry={() => setDocumentAttempt((value) => value + 1)}
/>
</div>
);
}
return (
<div className={`pdf-reader${mode === "vertical" ? " pdf-reader-vertical" : ""}`} ref={frameRef}>
{loadingDocument || !documentProxy ? (
<div className="reader-fallback">
<span>Ouverture PDF</span>
</div>
) : mode === "vertical" ? (
<div className="reader-vertical-stack">
{Array.from({ length: pages }, (_, index) => {
const pageNumber = index + 1;
return (
<PdfPageCanvas
key={pageNumber}
documentProxy={documentProxy}
pageNumber={pageNumber}
viewportSize={viewportSize}
zoom={zoom}
active={isPageInRenderWindow(pageNumber, currentPage, pages)}
/>
);
})}
</div>
) : pageError ? (
<div className="pdf-page-frame" data-reader-page={currentPage}>
<div className="reader-fallback reader-fallback-error">
<span>{pageError.detail}</span>
<button className="ghost-button" onClick={() => setPageAttempt((value) => value + 1)}>
Reessayer
</button>
<details>
<summary>Détail technique</summary>
<pre>{pageError.technicalDetail}</pre>
</details>
</div>
</div>
) : (
<div className="pdf-page-frame" data-reader-page={currentPage}>
{(!viewportSize || loadingPage || !pageRendered) && (
<div className="reader-fallback">
<span>Page {currentPage}</span>
</div>
)}
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
</div>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More