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
*.sqlite-* *.sqlite-*
*.tsbuildinfo *.tsbuildinfo
data/storage data/
Books/
coverage coverage
.pnpm-store .pnpm-store
.ideai/ .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. 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 ## Fonctionnalités MVP présentes
- Backend NestJS/Fastify exécutable avec healthcheck `GET /healthz`. - 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 : Volumes :
- `./data:/data` : base SQLite `/data/readabook.sqlite` et cache `/data/storage`. - `./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 : Variables principales :
- `JWT_SECRET` : secret JWT, à changer hors développement. - `JWT_SECRET` : secret JWT, à changer hors développement.
- `OPEN_LIBRARY_ENABLED=true|false` : active/désactive lenrichissement distant. - `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` - `DATABASE_PATH=/data/readabook.sqlite`
- `STORAGE_DIR=/data/storage` - `STORAGE_DIR=/data/storage`
@ -134,6 +146,16 @@ curl -b cookies.txt \
http://localhost:3000/admin/libraries 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 ## Lancer un scan
```bash ```bash

View File

@ -26,6 +26,7 @@
"fastify": "^5.2.1", "fastify": "^5.2.1",
"jose": "^5.9.6", "jose": "^5.9.6",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"node-unrar-js": "^2.0.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"zod": "^3.24.2" "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 { import {
CreateLibraryDto, CreateLibraryDto,
CreateLibrarySchema, CreateLibrarySchema,
CreateUserDto, CreateUserDto,
CreateUserSchema, CreateUserSchema,
UpdateAutomationSettingsDto,
UpdateAutomationSettingsSchema,
UpdateMetadataSourcesConfigDto,
UpdateMetadataSourcesConfigSchema,
UpdateLibraryDto, UpdateLibraryDto,
UpdateLibrarySchema, UpdateLibrarySchema,
UpdateUserDto, UpdateUserDto,
@ -13,9 +17,11 @@ import { AuthGuard } from "../auth/auth.guard.js";
import { Roles } from "../auth/roles.decorator.js"; import { Roles } from "../auth/roles.decorator.js";
import { RolesGuard } from "../auth/roles.guard.js"; import { RolesGuard } from "../auth/roles.guard.js";
import { AuthService } from "../auth/auth.service.js"; import { AuthService } from "../auth/auth.service.js";
import { AutomationService } from "../automation/automation.service.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js"; import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { JobsService } from "../jobs/jobs.service.js"; import { JobsService } from "../jobs/jobs.service.js";
import { LibrariesService } from "../libraries/libraries.service.js"; import { LibrariesService } from "../libraries/libraries.service.js";
import { MetadataService } from "../metadata/metadata.service.js";
import { ScannerService } from "../scanner/scanner.service.js"; import { ScannerService } from "../scanner/scanner.service.js";
@Controller("admin") @Controller("admin")
@ -26,7 +32,9 @@ export class AdminController {
private readonly auth: AuthService, private readonly auth: AuthService,
private readonly libraries: LibrariesService, private readonly libraries: LibrariesService,
private readonly jobs: JobsService, private readonly jobs: JobsService,
private readonly scanner: ScannerService private readonly scanner: ScannerService,
private readonly metadata: MetadataService,
private readonly automation: AutomationService
) {} ) {}
@Get("users") @Get("users")
@ -80,4 +88,34 @@ export class AdminController {
listJobs() { listJobs() {
return this.jobs.list(); 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 { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js"; import { AuthModule } from "../auth/auth.module.js";
import { AutomationModule } from "../automation/automation.module.js";
import { DatabaseModule } from "../database/database.module.js"; import { DatabaseModule } from "../database/database.module.js";
import { JobsModule } from "../jobs/jobs.module.js"; import { JobsModule } from "../jobs/jobs.module.js";
import { LibrariesService } from "../libraries/libraries.service.js"; import { LibrariesService } from "../libraries/libraries.service.js";
import { MetadataModule } from "../metadata/metadata.module.js";
import { ScannerModule } from "../scanner/scanner.module.js"; import { ScannerModule } from "../scanner/scanner.module.js";
import { AdminController } from "./admin.controller.js"; import { AdminController } from "./admin.controller.js";
@Module({ @Module({
imports: [AuthModule, DatabaseModule, JobsModule, ScannerModule], imports: [AuthModule, DatabaseModule, JobsModule, ScannerModule, MetadataModule, AutomationModule],
controllers: [AdminController], controllers: [AdminController],
providers: [LibrariesService] providers: [LibrariesService]
}) })

View File

@ -1,14 +1,16 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { AdminModule } from "./admin/admin.module.js"; import { AdminModule } from "./admin/admin.module.js";
import { AutomationModule } from "./automation/automation.module.js";
import { AuthModule } from "./auth/auth.module.js"; import { AuthModule } from "./auth/auth.module.js";
import { BooksModule } from "./books/books.module.js"; import { BooksModule } from "./books/books.module.js";
import { DatabaseModule } from "./database/database.module.js"; import { DatabaseModule } from "./database/database.module.js";
import { ProgressModule } from "./progress/progress.module.js"; import { ProgressModule } from "./progress/progress.module.js";
import { ReaderModule } from "./reader/reader.module.js";
import { ScannerModule } from "./scanner/scanner.module.js"; import { ScannerModule } from "./scanner/scanner.module.js";
import { HealthController } from "./health.controller.js"; import { HealthController } from "./health.controller.js";
@Module({ @Module({
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ScannerModule], imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ScannerModule, AutomationModule],
controllers: [HealthController] controllers: [HealthController]
}) })
export class AppModule {} 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 "@fastify/cookie";
import { FastifyReply } from "fastify"; 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 { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { AuthGuard } from "./auth.guard.js"; import { AuthGuard } from "./auth.guard.js";
import { AuthService } from "./auth.service.js"; import { AuthService } from "./auth.service.js";
@ -11,6 +18,11 @@ import { CurrentUser, CurrentUserParam } from "./current-user.js";
export class AuthController { export class AuthController {
constructor(private readonly auth: AuthService) {} constructor(private readonly auth: AuthService) {}
@Get("status")
async status() {
return this.auth.status();
}
@Post("bootstrap") @Post("bootstrap")
async bootstrap(@Body(new ZodValidationPipe(BootstrapAdminSchema)) body: BootstrapAdminDto) { async bootstrap(@Body(new ZodValidationPipe(BootstrapAdminSchema)) body: BootstrapAdminDto) {
return this.auth.bootstrapAdmin(body); return this.auth.bootstrapAdmin(body);
@ -40,4 +52,13 @@ export class AuthController {
me(@CurrentUserParam() user: CurrentUser) { me(@CurrentUserParam() user: CurrentUser) {
return { user }; 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 { eq } from "drizzle-orm";
import { SignJWT, jwtVerify } from "jose"; import { SignJWT, jwtVerify } from "jose";
import argon2 from "argon2"; 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 { DatabaseService } from "../database/database.service.js";
import { users } from "../database/schema.js"; import { users } from "../database/schema.js";
import { CurrentUser } from "./current-user.js"; import { CurrentUser } from "./current-user.js";
@Injectable() @Injectable()
export class AuthService { export class AuthService implements OnModuleInit {
readonly cookieName: string; readonly cookieName: string;
private readonly secret: Uint8Array; private readonly secret: Uint8Array;
@ -17,6 +17,10 @@ export class AuthService {
this.secret = new TextEncoder().encode(database.config.jwtSecret); this.secret = new TextEncoder().encode(database.config.jwtSecret);
} }
async onModuleInit(): Promise<void> {
await this.ensureInitialAdmin();
}
get cookieSecure(): boolean { get cookieSecure(): boolean {
return this.database.config.cookieSecure; return this.database.config.cookieSecure;
} }
@ -29,6 +33,26 @@ export class AuthService {
return this.createUser({ ...input, role: "admin" }); 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 }> { 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(); 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))) { if (!user || !(await argon2.verify(user.passwordHash, input.password))) {
@ -65,6 +89,35 @@ export class AuthService {
.all(); .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) { async createUser(input: CreateUserDto) {
const now = this.database.now(); const now = this.database.now();
const passwordHash = await argon2.hash(input.password); const passwordHash = await argon2.hash(input.password);
@ -125,4 +178,22 @@ export class AuthService {
.setExpirationTime("7d") .setExpirationTime("7d")
.sign(this.secret); .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 { FastifyReply } from "fastify";
import { lookup } from "mime-types"; import { lookup } from "mime-types";
import { BookQueryDto, BookQuerySchema } from "@readabook/shared"; import { BookQueryDto, BookQuerySchema } from "@readabook/shared";
@ -26,11 +26,31 @@ export class BooksController {
return this.books.get(Number(id)); 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") @Get(":id/file")
file(@Param("id") id: string, @Res() reply: FastifyReply) { file(@Param("id") id: string, @Headers("range") range: string | undefined, @Res() reply: FastifyReply) {
const { book, stream } = this.books.streamFile(Number(id)); const { book, stream, contentLength, contentType, end, partial, size, start } = this.books.streamFile(Number(id), range);
reply.header("Content-Type", lookup(book.filePath) || "application/octet-stream"); if (partial) {
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(book.title)}.${book.format}"`); 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); 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 { DatabaseModule } from "../database/database.module.js";
import { BooksController } from "./books.controller.js"; import { BooksController } from "./books.controller.js";
import { BooksService } from "./books.service.js"; import { BooksService } from "./books.service.js";
import { SeriesController } from "./series.controller.js";
@Module({ @Module({
imports: [AuthModule, DatabaseModule], imports: [AuthModule, DatabaseModule],
controllers: [BooksController], controllers: [BooksController, SeriesController],
providers: [BooksService], providers: [BooksService],
exports: [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 { BadRequestException, HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common";
import { createReadStream, existsSync } from "node:fs"; import { createReadStream, existsSync, statSync } from "node:fs";
import { extname } from "node:path";
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { BookQueryDto } from "@readabook/shared"; 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 { 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() @Injectable()
export class BooksService { export class BooksService {
@ -16,17 +20,33 @@ export class BooksService {
if (query.q) { if (query.q) {
return this.search(query.q, query.limit, query.offset); return this.search(query.q, query.limit, query.offset);
} }
return this.database.db const statement = this.database.db
.select() .select()
.from(books) .from(books)
.where(filters.length ? and(...filters) : undefined) .where(filters.length ? and(...filters) : undefined)
.orderBy(books.title) .orderBy(books.title);
.limit(query.limit) const rows =
.offset(query.offset) query.limit === undefined ? statement.all() : statement.limit(query.limit).offset(query.offset).all();
.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 const rows = this.database.sqlite
.prepare( .prepare(
` `
@ -39,10 +59,86 @@ export class BooksService {
` `
) )
.all(`${q.replace(/"/g, '""')}*`, limit, offset); .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) { 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(); const book = this.database.db.select().from(books).where(eq(books.id, id)).get();
if (!book) { if (!book) {
throw new NotFoundException("Book not found"); throw new NotFoundException("Book not found");
@ -50,48 +146,121 @@ export class BooksService {
return book; return book;
} }
streamFile(id: number) { private assertComicArchiveBook(book: typeof books.$inferSelect): void {
const book = this.get(id); if (book.format !== "cbz" && book.format !== "cbr") {
throw new BadRequestException("Book is not a comic archive");
}
if (!existsSync(book.filePath)) { if (!existsSync(book.filePath)) {
throw new NotFoundException("Book file not found on disk"); throw new NotFoundException("Book file not found on disk");
} }
return { book, stream: createReadStream(book.filePath) };
} }
streamCover(id: number) { private mapBookRow(row: Record<string, unknown>) {
const book = this.get(id); const seriesId = nullable(row.series_id);
if (!book.coverPath || !existsSync(book.coverPath)) { return {
throw new NotFoundException("Cover not found"); id: Number(row.id),
} libraryId: Number(row.library_id),
return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath }; 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() { private mapBookSelect(row: typeof books.$inferSelect) {
return this.database.db.select({ count: sql<number>`count(*)` }).from(books).get()?.count ?? 0; 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>) { function parseByteRange(range: string | undefined, size: number): { start: number; end: number; partial: boolean } {
return { if (!range) return { start: 0, end: Math.max(size - 1, 0), partial: false };
id: Number(row.id), const match = range.match(/^bytes=(\d*)-(\d*)$/);
libraryId: Number(row.library_id), if (!match || size <= 0) {
title: String(row.title), throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
author: nullable(row.author), }
description: nullable(row.description),
isbn: nullable(row.isbn), const [, rawStart, rawEnd] = match;
language: nullable(row.language), let start: number;
publisher: nullable(row.publisher), let end: number;
publishedDate: nullable(row.published_date), if (!rawStart && rawEnd) {
format: row.format, const suffixLength = Number(rawEnd);
filePath: String(row.file_path), start = Math.max(size - suffixLength, 0);
coverPath: nullable(row.cover_path), end = size - 1;
fileSize: Number(row.file_size), } else {
fileMtime: String(row.file_mtime), start = Number(rawStart);
createdAt: String(row.created_at), end = rawEnd ? Number(rawEnd) : size - 1;
updatedAt: String(row.updated_at) }
}; 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 { function nullable(value: unknown): string | null {
return value === null || value === undefined ? null : String(value); 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; cookieName: string;
cookieSecure: boolean; cookieSecure: boolean;
openLibraryEnabled: 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 { export function loadConfig(): AppConfig {
const databasePath = resolve(process.env.DATABASE_PATH ?? "./data/readabook.sqlite"); const databasePath = resolve(process.env.DATABASE_PATH ?? "./data/readabook.sqlite");
const storageDir = resolve(process.env.STORAGE_DIR ?? "./data/storage"); 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", jwtSecret: process.env.JWT_SECRET ?? "dev-change-me-readabook",
cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session", cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session",
cookieSecure: process.env.COOKIE_SECURE === "true", 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 Database from "better-sqlite3";
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3"; import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
import { AppConfig, loadConfig } from "../config/env.js"; import { AppConfig, loadConfig } from "../config/env.js";
import { extractSeriesVolume } from "../metadata/use-cases/extract-series-volume.js";
import * as schema from "./schema.js"; import * as schema from "./schema.js";
@Injectable() @Injectable()
@ -49,19 +50,39 @@ export class DatabaseService implements OnModuleDestroy {
updated_at TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, 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, title TEXT NOT NULL,
author TEXT, author TEXT,
description TEXT, description TEXT,
isbn TEXT, isbn TEXT,
isbn13 TEXT,
identifiers_json TEXT,
local_metadata_json TEXT,
language TEXT, language TEXT,
publisher TEXT, publisher TEXT,
published_date 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, file_path TEXT NOT NULL UNIQUE,
cover_path TEXT, 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_size INTEGER NOT NULL,
file_mtime TEXT NOT NULL, file_mtime TEXT NOT NULL,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@ -89,6 +110,26 @@ export class DatabaseService implements OnModuleDestroy {
updated_at TEXT NOT NULL 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( CREATE VIRTUAL TABLE IF NOT EXISTS book_fts USING fts5(
title, title,
author, author,
@ -119,6 +160,333 @@ export class DatabaseService implements OnModuleDestroy {
VALUES (new.id, new.title, new.author, new.description, new.isbn); VALUES (new.id, new.title, new.author, new.description, new.isbn);
END; 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')"); 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() 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( export const books = sqliteTable(
"books", "books",
{ {
@ -30,16 +44,26 @@ export const books = sqliteTable(
libraryId: integer("library_id") libraryId: integer("library_id")
.notNull() .notNull()
.references(() => libraries.id, { onDelete: "cascade" }), .references(() => libraries.id, { onDelete: "cascade" }),
seriesId: integer("series_id").references(() => series.id, { onDelete: "set null" }),
title: text("title").notNull(), title: text("title").notNull(),
author: text("author"), author: text("author"),
description: text("description"), description: text("description"),
isbn: text("isbn"), isbn: text("isbn"),
isbn13: text("isbn13"),
identifiersJson: text("identifiers_json"),
localMetadataJson: text("local_metadata_json"),
language: text("language"), language: text("language"),
publisher: text("publisher"), publisher: text("publisher"),
publishedDate: text("published_date"), 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(), filePath: text("file_path").notNull(),
coverPath: text("cover_path"), 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(), fileSize: integer("file_size").notNull(),
fileMtime: text("file_mtime").notNull(), fileMtime: text("file_mtime").notNull(),
createdAt: text("created_at").notNull(), createdAt: text("created_at").notNull(),
@ -48,6 +72,24 @@ export const books = sqliteTable(
(table) => ({ filePathIdx: uniqueIndex("books_file_path_unique").on(table.filePath) }) (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( export const progress = sqliteTable(
"progress", "progress",
{ {
@ -75,3 +117,23 @@ export const jobs = sqliteTable("jobs", {
createdAt: text("created_at").notNull(), createdAt: text("created_at").notNull(),
updatedAt: text("updated_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 { Injectable, OnModuleInit } from "@nestjs/common";
import { desc, eq } from "drizzle-orm"; import { desc, eq, inArray } from "drizzle-orm";
import { DatabaseService } from "../database/database.service.js"; import { DatabaseService } from "../database/database.service.js";
import { jobs } from "../database/schema.js"; import { jobs } from "../database/schema.js";
@Injectable() @Injectable()
export class JobsService { export class JobsService implements OnModuleInit {
constructor(private readonly database: DatabaseService) {} constructor(private readonly database: DatabaseService) {}
onModuleInit(): void {
this.failInterruptedJobs();
}
create(type: string, detail?: string) { create(type: string, detail?: string) {
const now = this.database.now(); const now = this.database.now();
return this.database.db return this.database.db
@ -43,4 +47,16 @@ export class JobsService {
list(limit = 50) { list(limit = 50) {
return this.database.db.select().from(jobs).orderBy(desc(jobs.createdAt)).limit(limit).all(); 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 { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import { accessSync, constants, realpathSync, statSync } from "node:fs"; import { and, eq, ne } from "drizzle-orm";
import { resolve } from "node:path";
import { eq } from "drizzle-orm";
import { CreateLibraryDto, UpdateLibraryDto } from "@readabook/shared"; import { CreateLibraryDto, UpdateLibraryDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js"; import { DatabaseService } from "../database/database.service.js";
import { libraries } from "../database/schema.js"; import { libraries } from "../database/schema.js";
import { LibraryPathValidationError, resolveLibraryPath } from "./library-path.js";
@Injectable() @Injectable()
export class LibrariesService { export class LibrariesService {
@ -24,6 +23,7 @@ export class LibrariesService {
create(input: CreateLibraryDto) { create(input: CreateLibraryDto) {
const path = this.validatePath(input.path); const path = this.validatePath(input.path);
this.ensurePathUnused(path);
const now = this.database.now(); const now = this.database.now();
return this.database.db return this.database.db
.insert(libraries) .insert(libraries)
@ -35,7 +35,10 @@ export class LibrariesService {
update(id: number, input: UpdateLibraryDto) { update(id: number, input: UpdateLibraryDto) {
const values: Partial<typeof libraries.$inferInsert> = { updatedAt: this.database.now() }; const values: Partial<typeof libraries.$inferInsert> = { updatedAt: this.database.now() };
if (input.name) values.name = input.name; 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; if (input.enabled !== undefined) values.enabled = input.enabled;
const library = this.database.db.update(libraries).set(values).where(eq(libraries.id, id)).returning().get(); const library = this.database.db.update(libraries).set(values).where(eq(libraries.id, id)).returning().get();
if (!library) { if (!library) {
@ -49,17 +52,29 @@ export class LibrariesService {
} }
private validatePath(input: string): string { private validatePath(input: string): string {
const resolved = resolve(input);
try { try {
accessSync(resolved, constants.R_OK); return resolveLibraryPath(input, this.database.config.libraryPathAliases);
const stats = statSync(resolved);
if (!stats.isDirectory()) {
throw new BadRequestException("Library path must be a directory");
}
return realpathSync(resolved);
} catch (error) { } catch (error) {
if (error instanceof BadRequestException) throw error; if (error instanceof LibraryPathValidationError) {
throw new BadRequestException("Library path is not readable"); 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(":bookId")
get(@CurrentUserParam() user: CurrentUser, @Param("bookId") bookId: string) { 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") @Put(":bookId")

View File

@ -30,6 +30,14 @@ export class ProgressService {
} }
get(userId: number, bookId: number) { 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 const row = this.database.db
.select({ .select({
bookId: progress.bookId, bookId: progress.bookId,
@ -40,10 +48,7 @@ export class ProgressService {
.from(progress) .from(progress)
.where(sql`${progress.userId} = ${userId} AND ${progress.bookId} = ${bookId}`) .where(sql`${progress.userId} = ${userId} AND ${progress.bookId} = ${bookId}`)
.get(); .get();
if (!row) { return row ?? null;
throw new NotFoundException("Progress not found");
}
return row;
} }
continueReading(userId: number) { 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 { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; 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"; 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", () => { 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 dir = mkdtempSync(join(tmpdir(), "readabook-"));
const file = join(dir, "Example.pdf"); const file = join(dir, "Example.pdf");
writeFileSync(file, "%PDF-1.4\n1 0 obj << /Title (My Book) /Author (Ada) >> endobj"); 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.title).toBe("My Book");
expect(metadata.author).toBe("Ada"); 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 { basename, dirname, extname, join } from "node:path";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import { XMLParser } from "fast-xml-parser"; 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 = { export type BookMetadata = {
title: string; title: string;
@ -21,11 +24,17 @@ const xmlParser = new XMLParser({
textNodeName: "#text" 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(); const extension = extname(filePath).toLowerCase();
if (extension === ".epub") { if (extension === ".epub") {
return extractEpubMetadata(filePath, storageDir); return extractEpubMetadata(filePath, storageDir);
} }
if (extension === ".cbz") {
return extractCbzMetadata(filePath, storageDir);
}
if (extension === ".cbr") {
return extractCbrMetadata(filePath, storageDir);
}
return extractPdfMetadata(filePath); return extractPdfMetadata(filePath);
} }
@ -56,7 +65,7 @@ function extractEpubMetadata(filePath: string, storageDir: string): BookMetadata
isbn, isbn,
language: firstText(metadata["dc:language"]), language: firstText(metadata["dc:language"]),
publisher: firstText(metadata["dc:publisher"]), publisher: firstText(metadata["dc:publisher"]),
publishedDate: firstText(metadata["dc:date"]), publishedDate: normalizePublishedDate(firstText(metadata["dc:date"])),
coverPath 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 { function fallbackMetadata(filePath: string): BookMetadata {
return { return {
title: basename(filePath, extname(filePath)), title: basename(filePath, extname(filePath)),
@ -139,6 +168,15 @@ function extractCover(zip: AdmZip, coverPathInZip: string, filePath: string, sto
return target; 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 { function matchPdfInfo(text: string, key: string): string | null {
return text.match(new RegExp(`/${key}\\s*\\(([^)]{1,500})\\)`))?.[1] ?? null; return text.match(new RegExp(`/${key}\\s*\\(([^)]{1,500})\\)`))?.[1] ?? null;
} }

View File

@ -1,12 +1,12 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module.js"; import { DatabaseModule } from "../database/database.module.js";
import { JobsModule } from "../jobs/jobs.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"; import { ScannerService } from "./scanner.service.js";
@Module({ @Module({
imports: [DatabaseModule, JobsModule], imports: [DatabaseModule, JobsModule, MetadataModule],
providers: [ScannerService, OpenLibraryService], providers: [ScannerService],
exports: [ScannerService] exports: [ScannerService]
}) })
export class ScannerModule {} 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 { Injectable, NotFoundException } from "@nestjs/common";
import { readdirSync, statSync } from "node:fs"; import { existsSync, readdirSync, statSync } from "node:fs";
import { extname, join } from "node:path"; import { basename, extname, join } from "node:path";
import { eq } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { DatabaseService } from "../database/database.service.js"; 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 { 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 { extractMetadata } from "./metadata.js";
import { OpenLibraryService } from "./open-library.service.js";
@Injectable() @Injectable()
export class ScannerService { export class ScannerService {
constructor( constructor(
private readonly database: DatabaseService, private readonly database: DatabaseService,
private readonly jobs: JobsService, private readonly jobs: JobsService,
private readonly openLibrary: OpenLibraryService private readonly metadata: MetadataService
) {} ) {}
enqueueLibraryScan(libraryId: number) { enqueueLibraryScan(libraryId: number) {
@ -28,51 +30,271 @@ export class ScannerService {
return job; 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> { private async scanLibrary(jobId: number, library: typeof libraries.$inferSelect): Promise<void> {
this.jobs.markRunning(jobId, `Scanning ${library.path}`); this.jobs.markRunning(jobId, `Scanning ${library.path}`);
let count = 0; let count = 0;
const failures: ScanFailure[] = [];
const seen = new Set<string>();
for (const filePath of walkBooks(library.path)) { for (const filePath of walkBooks(library.path)) {
await this.ingestFile(library.id, filePath); seen.add(filePath);
count += 1; 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> { private async ingestFile(libraryId: number, filePath: string): Promise<void> {
const stats = statSync(filePath); const stats = statSync(filePath);
let metadata = extractMetadata(filePath, this.database.config.storageDir); const existing = this.database.db.select().from(books).where(eq(books.filePath, filePath)).get();
if (this.database.config.openLibraryEnabled) { if (existing) {
try { this.database.db
metadata = { ...metadata, ...(await this.openLibrary.enrich(metadata)) }; .update(books)
} catch { .set({ scanStatus: "running", enrichmentStatus: "running", updatedAt: this.database.now() })
// Remote enrichment is opportunistic; local ingestion must stay deterministic. .where(eq(books.id, existing.id))
} .run();
} }
const localMetadata = await extractMetadata(filePath, this.database.config.storageDir);
const now = this.database.now(); const now = this.database.now();
const format: "epub" | "pdf" = extname(filePath).toLowerCase() === ".epub" ? "epub" : "pdf"; const format = bookFormatFromPath(filePath);
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get(); const shouldRemoteEnrich = !existing ? this.shouldAutoEnrichNewBooks() : true;
const metadata = await this.metadata.enrichMetadata(localMetadata, filePath, { remote: shouldRemoteEnrich });
const seriesInfo = this.resolveSeries(metadata.title, filePath);
const values = { const values = {
libraryId, libraryId,
seriesId: seriesInfo.seriesId,
title: metadata.title, title: metadata.title,
author: metadata.author, author: metadata.author,
description: metadata.description, description: metadata.description,
isbn: metadata.isbn, isbn: metadata.isbn,
isbn13: metadata.isbn13,
identifiersJson: metadata.identifiersJson,
localMetadataJson: metadata.localMetadataJson,
language: metadata.language, language: metadata.language,
publisher: metadata.publisher, publisher: metadata.publisher,
publishedDate: metadata.publishedDate, publishedDate: metadata.publishedDate,
volumeNumber: seriesInfo.volumeNumber,
volumeLabel: seriesInfo.volumeLabel,
format, format,
filePath, filePath,
coverPath: metadata.coverPath, coverPath: metadata.coverPath,
metadataStatus: metadata.metadataStatus,
metadataProvenanceJson: metadata.metadataProvenanceJson,
scanStatus: "succeeded" as const,
enrichmentStatus: shouldRemoteEnrich ? ("succeeded" as const) : ("idle" as const),
fileSize: stats.size, fileSize: stats.size,
fileMtime: stats.mtime.toISOString(), fileMtime: stats.mtime.toISOString(),
updatedAt: now updatedAt: now
}; };
existing this.upsertBookByFilePath(values, existing, now);
? this.database.db.update(books).set(values).where(eq(books.id, existing.id)).returning().get()
: this.database.db.insert(books).values({ ...values, createdAt: now }).returning().get();
} }
private ingestIncompleteFile(libraryId: number, filePath: string): void {
const stats = statSync(filePath);
const now = this.database.now();
const existing = this.database.db.select().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> { function* walkBooks(root: string): Generator<string> {
@ -84,8 +306,62 @@ function* walkBooks(root: string): Generator<string> {
} }
if (!entry.isFile()) continue; if (!entry.isFile()) continue;
const extension = extname(entry.name).toLowerCase(); const extension = extname(entry.name).toLowerCase();
if (extension === ".epub" || extension === ".pdf") { if (extension === ".epub" || extension === ".pdf" || extension === ".cbz" || extension === ".cbr") {
yield path; 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; root /usr/share/nginx/html;
index index.html; index index.html;
location /auth/ { location = /sw.js {
proxy_pass http://api:3000/auth/; 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
} }
location /admin/ { location ^~ /admin {
proxy_pass http://api:3000/admin/; if ($http_accept ~* "text/html") {
rewrite ^ /index.html last;
}
proxy_pass http://api:3000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
} }
location /books/ { location ^~ /books {
proxy_pass http://api:3000/books/; proxy_pass http://api:3000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
} }
location /progress/ { location ^~ /series {
proxy_pass http://api:3000/progress/; 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
} }

View File

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

View File

@ -1,5 +1,5 @@
const CACHE_NAME = "readabook-shell-v1"; const CACHE_NAME = "readabook-shell-v2";
const SHELL = ["/", "/home", "/manifest.webmanifest", "/icons/readabook.svg"]; const SHELL = ["/", "/index.html", "/manifest.webmanifest", "/icons/readabook.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL))); 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))) { if (event.request.method !== "GET" || ["/auth", "/admin", "/books", "/progress"].some((path) => url.pathname.startsWith(path))) {
return; 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 { useEffect, useState } from "react";
import type { Session } from "./api/types"; import type { Session } from "./api/types";
import { api } from "./api/client"; import { api } from "./api/client";
import { isPrivateRoute } from "./auth/routing";
import { AppShell } from "./layout/AppShell"; import { AppShell } from "./layout/AppShell";
import { AdminAutomationPage } from "./pages/AdminAutomationPage";
import { AdminPage } from "./pages/AdminPage"; import { AdminPage } from "./pages/AdminPage";
import { BookPage } from "./pages/BookPage"; import { BookPage } from "./pages/BookPage";
import { HomePage } from "./pages/HomePage"; import { HomePage } from "./pages/HomePage";
@ -10,8 +12,9 @@ import { LoginPage } from "./pages/LoginPage";
import { ProfilePage } from "./pages/ProfilePage"; import { ProfilePage } from "./pages/ProfilePage";
import { ReaderPage } from "./pages/ReaderPage"; import { ReaderPage } from "./pages/ReaderPage";
import { SearchPage } from "./pages/SearchPage"; import { SearchPage } from "./pages/SearchPage";
import { SeriesPage } from "./pages/SeriesPage";
import { SetupPage } from "./pages/SetupPage"; 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>) { function renderRoute(route: Route, session: Session, refreshSession: () => Promise<void>) {
if (route.name === "login") return <LoginPage onSessionChange={refreshSession} />; if (route.name === "login") return <LoginPage onSessionChange={refreshSession} />;
@ -22,6 +25,8 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
<HomePage /> <HomePage />
) : route.name === "library" ? ( ) : route.name === "library" ? (
<LibraryPage libraryId={route.libraryId} /> <LibraryPage libraryId={route.libraryId} />
) : route.name === "catalogSeries" ? (
<SeriesPage seriesName={route.seriesName} />
) : route.name === "book" ? ( ) : route.name === "book" ? (
<BookPage bookId={route.bookId} /> <BookPage bookId={route.bookId} />
) : route.name === "reader" ? ( ) : route.name === "reader" ? (
@ -30,19 +35,27 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
<SearchPage /> <SearchPage />
) : route.name === "me" ? ( ) : route.name === "me" ? (
<ProfilePage session={session} onSessionChange={refreshSession} /> <ProfilePage session={session} onSessionChange={refreshSession} />
) : route.name === "admin" && route.section === "automation" ? (
<AdminAutomationPage />
) : ( ) : (
<AdminPage /> <AdminPage />
); );
return <AppShell session={session}>{content}</AppShell>; return (
<AppShell session={session} readerLayout={route.name === "reader"}>
{content}
</AppShell>
);
} }
export function App() { export function App() {
const [route, setRoute] = useState(parseRoute()); const [route, setRoute] = useState(parseRoute());
const [session, setSession] = useState<Session>({ user: null, degraded: false }); const [session, setSession] = useState<Session>({ user: null, degraded: false });
const [sessionChecked, setSessionChecked] = useState(false);
async function refreshSession() { async function refreshSession() {
setSession(await api.session()); setSession(await api.session());
setSessionChecked(true);
} }
useEffect(() => { useEffect(() => {
@ -55,5 +68,35 @@ export function App() {
return () => window.removeEventListener("popstate", listener); 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); 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 { import type {
BookDto, BookDto,
BookQueryDto, BookQueryDto,
AuthStatusDto,
AutomationSettingsDto,
BootstrapAdminDto, BootstrapAdminDto,
CreateLibraryDto, CreateLibraryDto,
JobDto, JobDto,
LibraryDto, LibraryDto,
LoginDto, LoginDto,
MetadataSourcesConfigDto,
ProgressDto, ProgressDto,
UpdateAutomationSettingsDto,
UpdateAccountDto,
UpdateMetadataSourcesConfigDto,
UpdateProgressDto, UpdateProgressDto,
UserDto UserDto
} from "@readabook/shared"; } from "@readabook/shared";
import { mockBooks, mockContinue, mockJobs, mockLibraries, mockProgress, mockUser } from "./mockData"; import {
import type { ContinueItem, Session } from "./types"; 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 API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:";
type RequestOptions = RequestInit & { type RequestOptions = RequestInit & {
fallback?: unknown; 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> { async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
try { try {
const response = await fetch(`${API_BASE}${path}`, { const response = await fetch(`${API_BASE}${path}`, {
...options, ...options,
credentials: "include", credentials: "include",
headers: { headers: requestHeaders(options)
"Content-Type": "application/json",
...options.headers
}
}); });
if (!response.ok) { if (!response.ok) {
if (response.status === 401 && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("readabook:session-expired"));
}
const detail = await response.text(); 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; return (await response.json()) as T;
@ -62,6 +112,30 @@ function queryString(query: Partial<BookQueryDto>): string {
return value ? `?${value}` : ""; 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 = { export const api = {
async session(): Promise<Session> { async session(): Promise<Session> {
try { try {
@ -71,6 +145,9 @@ export const api = {
return { user: null, degraded: false }; return { user: null, degraded: false };
} }
}, },
async authStatus(): Promise<AuthStatusDto> {
return request<AuthStatusDto>("/auth/status");
},
async bootstrap(input: BootstrapAdminDto): Promise<UserDto> { async bootstrap(input: BootstrapAdminDto): Promise<UserDto> {
const result = await request<UserDto>("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) }); const result = await request<UserDto>("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) });
return result; return result;
@ -82,11 +159,14 @@ export const api = {
async logout(): Promise<void> { async logout(): Promise<void> {
await request<{ ok: true }>("/auth/logout", { method: "POST" }); 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[]> { 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[]> { 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> { async book(id: number): Promise<BookDto> {
const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0]; const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0];
@ -98,6 +178,12 @@ export const api = {
bookCoverUrl(id: number): string { bookCoverUrl(id: number): string {
return `${API_BASE}/books/${id}/cover`; 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> { async progress(bookId: number): Promise<ProgressDto | null> {
try { try {
return await request<ProgressDto>(`/progress/${bookId}`, { return await request<ProgressDto>(`/progress/${bookId}`, {
@ -115,6 +201,31 @@ export const api = {
fallback: { bookId, ...input, updatedAt: new Date().toISOString() } 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[]> { async continueReading(): Promise<ContinueItem[]> {
return request<ContinueItem[]>("/progress/continue", { fallback: mockContinue }); return request<ContinueItem[]>("/progress/continue", { fallback: mockContinue });
}, },
@ -124,17 +235,45 @@ export const api = {
async createLibrary(input: CreateLibraryDto): Promise<LibraryDto> { async createLibrary(input: CreateLibraryDto): Promise<LibraryDto> {
return request<LibraryDto>("/admin/libraries", { return request<LibraryDto>("/admin/libraries", {
method: "POST", method: "POST",
body: JSON.stringify(input), body: JSON.stringify(input)
fallback: { id: Date.now(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...input }
}); });
}, },
async deleteLibrary(id: number): Promise<void> {
await request<{ ok: true }>(`/admin/libraries/${id}`, { method: "DELETE" });
},
async scanLibrary(id: number): Promise<JobDto> { 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[]> { async jobs(): Promise<JobDto[]> {
return request<JobDto[]>("/admin/jobs", { fallback: mockJobs }); return request<JobDto[]>("/admin/jobs", { fallback: mockJobs });
}, },
async users(): Promise<UserDto[]> { async users(): Promise<UserDto[]> {
return request<UserDto[]>("/admin/users", { fallback: [mockUser] }); 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"; import type { ContinueItem } from "./types";
const now = new Date().toISOString(); 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 = { export const mockUser: UserDto = {
id: 1, id: 1,
email: "admin@readabook.local", email: "admin@readabook.local",
@ -17,13 +36,14 @@ export const mockLibraries: LibraryDto[] = [
]; ];
export const mockBooks: BookDto[] = [ export const mockBooks: BookDto[] = [
{ mockBook({
id: 1, id: 1,
libraryId: 1, libraryId: 1,
title: "L'Herbier des machines", title: "L'Herbier des machines",
author: "M. Valrose", author: "M. Valrose",
description: "Fragments, croquis et notes rassemblees autour d'automates introuvables.", description: "Fragments, croquis et notes rassemblees autour d'automates introuvables.",
isbn: null, isbn: null,
isbn13: null,
language: "fr", language: "fr",
publisher: "Cabinet ReadaBook", publisher: "Cabinet ReadaBook",
publishedDate: "1908", publishedDate: "1908",
@ -34,14 +54,15 @@ export const mockBooks: BookDto[] = [
fileMtime: now, fileMtime: now,
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now
}, }),
{ mockBook({
id: 2, id: 2,
libraryId: 2, libraryId: 2,
title: "Cartographie des songes", title: "Cartographie des songes",
author: "I. Nadir", author: "I. Nadir",
description: "Un atlas annote ou chaque page devient une vitrine de lecture.", description: "Un atlas annote ou chaque page devient une vitrine de lecture.",
isbn: null, isbn: null,
isbn13: null,
language: "fr", language: "fr",
publisher: "ReadaBook", publisher: "ReadaBook",
publishedDate: "1921", publishedDate: "1921",
@ -52,12 +73,52 @@ export const mockBooks: BookDto[] = [
fileMtime: now, fileMtime: now,
createdAt: now, createdAt: now,
updatedAt: 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[] = [ export const mockProgress: ProgressDto[] = [
{ bookId: 1, locator: "mock:chapter-3", percent: 42, updatedAt: now }, { 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) => ({ export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({
@ -68,3 +129,20 @@ export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({
export const mockJobs: JobDto[] = [ export const mockJobs: JobDto[] = [
{ id: 1, type: "scan-library", status: "succeeded", detail: "2 ouvrages indexes", error: null, createdAt: now, updatedAt: now } { 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[]; libraries: LibraryDto[];
jobs: JobDto[]; 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 { BookOpen, Eye } from "lucide-react";
import type { BookDto } from "@readabook/shared"; import type { BookDto } from "@readabook/shared";
import { api } from "../api/client"; import { api } from "../api/client";
import { bookCardVolumeLabel, bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, displayPublishedDate } from "../book/metadata";
import { navigate } from "../router"; import { navigate } from "../router";
import { FormatPill } from "./ui"; 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 ( return (
<article className={`book-card ${compact ? "book-card-compact" : ""}`}> <article className={`book-card ${compact ? "book-card-compact" : ""}`}>
<button className="cover-button" onClick={() => navigate(`/book/${book.id}`)} aria-label={`Ouvrir ${book.title}`}> <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} />} {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> </button>
<div className="book-card-body"> <div className="book-card-body">
<div className="book-card-meta"> <div className="book-card-meta">
<FormatPill format={book.format} /> <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> </div>
<h3>{book.title}</h3> <h3>{bookDisplayTitle(book)}</h3>
<p>{book.author ?? "Auteur inconnu"}</p> <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>} {!compact && <p className="book-card-description">{book.description ?? "Notice absente du catalogue."}</p>}
<div className="book-card-actions"> <div className="book-card-actions">
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}> <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>; 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>; 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 { ReactNode } from "react";
import type { Session } from "../api/types"; import type { Session } from "../api/types";
import { navigate } from "../router"; import { navigate } from "../router";
@ -7,10 +7,11 @@ const navItems = [
{ href: "/home", label: "Accueil", icon: Home }, { href: "/home", label: "Accueil", icon: Home },
{ href: "/search", label: "Recherche", icon: Search }, { href: "/search", label: "Recherche", icon: Search },
{ href: "/admin/libraries", label: "Admin", icon: Settings }, { href: "/admin/libraries", label: "Admin", icon: Settings },
{ href: "/admin/automation", label: "Automatisation", icon: SlidersHorizontal },
{ href: "/me", label: "Profil", icon: UserRound } { 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 ( return (
<div className="app-shell"> <div className="app-shell">
<aside className="side-rail"> <aside className="side-rail">
@ -31,7 +32,7 @@ export function AppShell({ children, session }: { children: ReactNode; session:
</nav> </nav>
<div className="session-chip">{session.user ? session.user.email : "Mode vitrine"}</div> <div className="session-chip">{session.user ? session.user.email : "Mode vitrine"}</div>
</aside> </aside>
<main>{children}</main> <main className={readerLayout ? "app-main-reader" : undefined}>{children}</main>
</div> </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 { 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 type { JobDto, LibraryDto, UserDto } from "@readabook/shared";
import { api } from "../api/client"; import { api, getApiFallback } from "../api/client";
import { ErrorRibbon, LoadingState, Panel } from "../components/ui"; 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() { export function AdminPage() {
const [libraries, setLibraries] = useState<LibraryDto[] | null>(null); const [libraries, setLibraries] = useState<LibraryDto[]>([]);
const [jobs, setJobs] = useState<JobDto[]>([]); const [jobs, setJobs] = useState<JobDto[]>([]);
const [users, setUsers] = useState<UserDto[]>([]); const [users, setUsers] = useState<UserDto[]>([]);
const [loading, setLoading] = useState(true);
const [name, setName] = useState("Bibliotheque locale"); const [name, setName] = useState("Bibliotheque locale");
const [path, setPath] = useState("/library"); const [path, setPath] = useState("/library");
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [success, setSuccess] = useState<string>();
const [scanRetryLibrary, setScanRetryLibrary] = useState<LibraryDto>();
async function refresh() { async function refresh() {
const [nextLibraries, nextJobs, nextUsers] = await Promise.all([api.libraries(), api.jobs(), api.users()]); setLoading(true);
setLibraries(nextLibraries); setError(undefined);
setJobs(nextJobs); const [libraryResult, jobResult, userResult] = await Promise.allSettled([api.libraries(), api.jobs(), api.users()]);
setUsers(nextUsers); 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(() => { useEffect(() => {
refresh().catch((refreshError) => setError(refreshError instanceof Error ? refreshError.message : "Administration indisponible")); void refresh();
}, []); }, []);
async function createLibrary(event: FormEvent) { async function createLibrary(event: FormEvent) {
event.preventDefault(); event.preventDefault();
setError(undefined); setError(undefined);
setSuccess(undefined);
setScanRetryLibrary(undefined);
try { try {
await api.createLibrary({ name, path, enabled: true }); const created = await api.createLibrary({ name, path, enabled: true });
await refresh(); 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) { } 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) { async function scan(id: number) {
setError(undefined); setError(undefined);
setSuccess(undefined);
setScanRetryLibrary(undefined);
try { try {
await api.scanLibrary(id); await api.scanLibrary(id);
await refresh(); await refresh();
setSuccess("Scan demandé.");
} catch (scanError) { } 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 ( return (
<div className="page-grid"> <div className="page-grid">
<Panel className="span-2"> <Panel className="span-2">
<div className="section-heading"> <div className="section-heading">
<h1>Administration</h1> <h1>Administration</h1>
<span>{users.length} comptes</span> <span>{loading ? "chargement" : `${users.length} comptes`}</span>
</div> </div>
<ErrorRibbon message={error} /> <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}> <form className="admin-form" onSubmit={createLibrary}>
<label> <label>
Nom du rayon Nom de la bibliothèque
<input value={name} onChange={(event) => setName(event.target.value)} required /> <input value={name} onChange={(event) => setName(event.target.value)} required />
<small>Nom affiché dans lapplication, par exemple : Romans, BD, Documentation.</small>
</label> </label>
<label> <label>
Chemin serveur Chemin du dossier
<input value={path} onChange={(event) => setPath(event.target.value)} required /> <input value={path} onChange={(event) => setPath(event.target.value)} required />
<small>Emplacement réel sur le serveur ReadaBook lit les livres.</small>
</label> </label>
<button className="primary-button" type="submit"> <button className="primary-button" type="submit">
<Plus size={17} /> <Plus size={17} />
@ -75,32 +160,54 @@ export function AdminPage() {
<h2>Travaux</h2> <h2>Travaux</h2>
<span>{jobs.length}</span> <span>{jobs.length}</span>
</div> </div>
<div className="job-list"> {loading && !jobs.length ? (
{jobs.map((job) => ( <LoadingState label="Lecture des travaux" />
<div key={job.id}> ) : jobs.length ? (
<strong>{job.type}</strong> <div className="job-list">
<span>{job.status}</span> {jobs.map((job) => (
</div> <div key={job.id}>
))} <div className="job-copy">
</div> <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>
<Panel className="span-3"> <Panel className="span-3">
<div className="library-table"> {loading && !libraries.length ? (
{libraries.map((library) => ( <LoadingState label="Lecture des rayons" />
<div key={library.id}> ) : libraries.length ? (
<div> <div className="library-table">
<strong>{library.name}</strong> {libraries.map((library) => (
<span>{library.path}</span> <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> </div>
<span>{library.enabled ? "actif" : "pause"}</span> ))}
<button className="ghost-button" onClick={() => scan(library.id)}> </div>
<Play size={16} /> ) : (
Scanner <EmptyState title="Aucun rayon" detail="Ajoute un chemin puis relance la synchronisation." />
</button> )}
</div>
))}
</div>
</Panel> </Panel>
</div> </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 { 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 type { BookDto, ProgressDto } from "@readabook/shared";
import { api } from "../api/client"; 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"; import { navigate } from "../router";
export function BookPage({ bookId }: { bookId: number }) { export function BookPage({ bookId }: { bookId: number }) {
const [book, setBook] = useState<BookDto | null>(null); const [book, setBook] = useState<BookDto | null>(null);
const [progress, setProgress] = useState<ProgressDto | 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(() => { useEffect(() => {
let alive = true; let alive = true;
Promise.all([api.book(bookId), api.progress(bookId)]).then(([nextBook, nextProgress]) => { loadBook().finally(() => {
if (!alive) return; if (!alive) return;
setBook(nextBook);
setProgress(nextProgress);
}); });
return () => { return () => {
alive = false; alive = false;
}; };
}, [bookId]); }, [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 ( return (
<div className="book-detail"> <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} />} {book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={72} />}
</section> </section>
<Panel className="book-facts"> <Panel className="book-facts">
<ErrorRibbon message={error} />
<div className="book-card-meta"> <div className="book-card-meta">
<FormatPill format={book.format} /> <FormatPill format={book.format} />
<span>{book.language ?? "langue inconnue"}</span> <span>{book.language ?? "langue inconnue"}</span>
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
</div> </div>
<h1>{book.title}</h1> <h1>{bookDisplayTitle(book)}</h1>
<p className="lead">{book.author ?? "Auteur inconnu"}</p> <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} />} {progress && <Meter value={progress.percent} />}
<div className="book-card-actions"> <div className="book-card-actions">
<button className="primary-button" onClick={() => navigate(`/reader/${book.id}`)}> <button className="primary-button" onClick={() => navigate(`/reader/${book.id}`)}>
@ -46,6 +108,18 @@ export function BookPage({ bookId }: { bookId: number }) {
<LibraryBig size={18} /> <LibraryBig size={18} />
Rayon Rayon
</button> </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> </div>
</Panel> </Panel>
</div> </div>

View File

@ -1,21 +1,19 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LibraryBig, ScanLine } from "lucide-react"; import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
import { api } from "../api/client"; import { api } from "../api/client";
import type { DashboardData } from "../api/types"; 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 { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
import { navigate } from "../router"; import { navigate } from "../router";
export function HomePage() { export function HomePage() {
const [state, setState] = useState<DashboardData | null>(null); const [state, setState] = useState<DashboardData | null>(null);
const [fallback, setFallback] = useState(false);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
Promise.all([api.books(), api.continueReading(), api.libraries(), api.jobs()]) Promise.all([api.books(), api.continueReading(), api.libraries(), api.jobs()])
.then(([books, continueReading, libraries, jobs]) => { .then(([books, continueReading, libraries, jobs]) => {
if (!alive) return; if (!alive) return;
setFallback(books.some((book) => book.filePath.startsWith("/library/")) && jobs.length === 1);
setState({ books, continueReading, libraries, jobs }); setState({ books, continueReading, libraries, jobs });
}) })
.catch(() => { .catch(() => {
@ -28,13 +26,32 @@ export function HomePage() {
if (!state) return <LoadingState />; 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 ( return (
<div className="page-grid"> <div className="page-grid home-page">
<section className="hero-band"> <section className="hero-band home-hero">
<div> <div>
<p>Cabinet de curiosites numerique</p> <h1>Reprendre la lecture.</h1>
<h1>Ouvrir, classer, reprendre.</h1>
<span>{fallback ? "API absente ou incomplete : specimens de demonstration actifs." : "Catalogue branche sur le serveur local."}</span>
</div> </div>
<button className="primary-button" onClick={() => navigate("/search")}> <button className="primary-button" onClick={() => navigate("/search")}>
<ScanLine size={18} /> <ScanLine size={18} />
@ -44,18 +61,17 @@ export function HomePage() {
<Panel className="span-2"> <Panel className="span-2">
<div className="section-heading"> <div className="section-heading">
<h2>Reprise de lecture</h2> <h2>Livres en cours</h2>
<span>{state.continueReading.length} traces</span> <span>{state.continueReading.length} lectures</span>
</div> </div>
{state.continueReading.length ? ( {state.continueReading.length ? (
<div className="continue-grid"> <div className="continue-grid">
{state.continueReading.map((item) => ( {state.continueReading.map((item) =>
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}> renderHomeBook(item.book, "home-book-card continue-tile", {
<strong>{item.book.title}</strong> href: `/reader/${item.book.id}`,
<span>{item.book.author ?? "Auteur inconnu"}</span> progressPercent: item.progress.percent
<Meter value={item.progress.percent} /> })
</button> )}
))}
</div> </div>
) : ( ) : (
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." /> <EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
@ -77,10 +93,8 @@ export function HomePage() {
</div> </div>
</Panel> </Panel>
<section className="book-grid span-3"> <section className="home-book-grid span-3">
{state.books.map((book) => ( {state.books.map((book) => renderHomeBook(book))}
<BookCard key={book.id} book={book} />
))}
</section> </section>
</div> </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 { 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 { api } from "../api/client";
import { hasActiveCoverWork, isBookCoverUpdating } from "../api/types";
import { BookCard } from "../components/BookCard"; import { BookCard } from "../components/BookCard";
import { EmptyState, LoadingState, Panel } from "../components/ui"; import { EmptyState, LoadingState, Panel } from "../components/ui";
export function LibraryPage({ libraryId }: { libraryId: number }) { export function LibraryPage({ libraryId }: { libraryId: number }) {
const [books, setBooks] = useState<BookDto[] | null>(null); const [books, setBooks] = useState<BookDto[] | null>(null);
const [libraries, setLibraries] = useState<LibraryDto[]>([]); const [libraries, setLibraries] = useState<LibraryDto[]>([]);
const [jobs, setJobs] = useState<JobDto[]>([]);
useEffect(() => { useEffect(() => {
let alive = true; 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; if (!alive) return;
setBooks(nextBooks); setBooks(nextBooks);
setLibraries(nextLibraries); setLibraries(nextLibraries);
setJobs(nextJobs);
}); });
return () => { return () => {
alive = false; alive = false;
@ -22,6 +25,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) {
if (!books) return <LoadingState />; if (!books) return <LoadingState />;
const library = libraries.find((item) => item.id === libraryId); const library = libraries.find((item) => item.id === libraryId);
const fallbackCoverLoading = hasActiveCoverWork(jobs);
return ( return (
<div className="page-grid"> <div className="page-grid">
@ -37,7 +41,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) {
{books.length ? ( {books.length ? (
<section className="book-grid span-3"> <section className="book-grid span-3">
{books.map((book) => ( {books.map((book) => (
<BookCard key={book.id} book={book} /> <BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
))} ))}
</section> </section>
) : ( ) : (

View File

@ -1,14 +1,36 @@
import { FormEvent, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { KeyRound, LogIn } from "lucide-react"; import { KeyRound, LogIn, ShieldAlert } from "lucide-react";
import type { AuthStatusDto } from "@readabook/shared";
import { api } from "../api/client"; import { api } from "../api/client";
import { loginErrorMessage } from "../auth/errors";
import { navigate } from "../router"; import { navigate } from "../router";
import { ErrorRibbon, Panel } from "../components/ui"; import { ErrorRibbon, Panel } from "../components/ui";
const DEFAULT_INITIAL_PASSWORD = "readabook-admin-change-me";
export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<void> }) { 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 [password, setPassword] = useState("");
const [error, setError] = useState<string>(); 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) { async function submit(event: FormEvent) {
event.preventDefault(); event.preventDefault();
setError(undefined); setError(undefined);
@ -17,7 +39,7 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
await onSessionChange(); await onSessionChange();
navigate("/home"); navigate("/home");
} catch (loginError) { } 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} /> <KeyRound size={24} />
<h2>Entrer dans le cabinet</h2> <h2>Entrer dans le cabinet</h2>
<ErrorRibbon message={error} /> <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"> <form onSubmit={submit} className="stack-form">
<label> <label>
Email Email
@ -46,9 +85,11 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
Se connecter Se connecter
</button> </button>
</form> </form>
<button className="ghost-button full-width" onClick={() => navigate("/setup/admin")}> {status && !status.hasUsers && (
Initialiser le premier admin <button className="ghost-button full-width" onClick={() => navigate("/setup/admin")}>
</button> Initialiser le premier admin
</button>
)}
</Panel> </Panel>
</div> </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 type { Session } from "../api/types";
import { api } from "../api/client"; import { api } from "../api/client";
import { Panel } from "../components/ui"; import { ErrorRibbon, Panel } from "../components/ui";
import { navigate } from "../router"; import { navigate } from "../router";
export function ProfilePage({ session, onSessionChange }: { session: Session; onSessionChange: () => Promise<void> }) { 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() { async function logout() {
await api.logout(); await api.logout();
await onSessionChange(); await onSessionChange();
navigate("/login"); 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 ( return (
<div className="page-grid"> <div className="page-grid">
<Panel className="span-2 profile-panel"> <Panel className="profile-panel">
<UserRound size={28} /> <div className="profile-identity">
<h1>{session.user?.name ?? "Lecteur invite"}</h1> <span className="profile-avatar" aria-hidden="true">
<p>{session.user?.email ?? "Session non connectee"}</p> <UserRound size={28} />
<span>{session.user?.role ?? "vitrine"}</span> </span>
<button className="ghost-button" onClick={logout}> <h1>{session.user?.name ?? "Lecteur invite"}</h1>
<LogOut size={17} /> <p>{session.user?.email ?? "Session non connectee"}</p>
Sortir <span>{session.user?.role ?? "vitrine"}</span>
</button> </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> </Panel>
</div> </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 { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react";
import { ArrowLeft, Save } from "lucide-react";
import type { BookDto } from "@readabook/shared"; import type { BookDto } from "@readabook/shared";
import { api } from "../api/client"; import { api } from "../api/client";
import { LoadingState, Meter } from "../components/ui"; import { CbzReader } from "../reader/CbzReader";
import { navigate } from "../router";
import { EpubReader } from "../reader/EpubReader"; import { EpubReader } from "../reader/EpubReader";
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
import { PdfReader } from "../reader/PdfReader"; 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 { 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 }) { export function ReaderPage({ bookId }: { bookId: number }) {
const [book, setBook] = useState<BookDto | null>(null); const [book, setBook] = useState<BookDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();
const [page, setPage] = useState(1); 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(() => { useEffect(() => {
api.book(bookId).then(setBook); void loadBook();
}, [bookId]); }, [bookId]);
useEffect(() => { 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]); }, [progress]);
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]); const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
const savePdfPage = useCallback( const savePdfPage = useCallback(
(nextPage: number, pages: number) => { (nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
setPage(nextPage); 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 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 ( return (
<div className="reader-page"> <ReaderShell
<header className="reader-topbar"> title={book?.title ?? "Ouverture du lecteur"}
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}> backHref={backHref}
<ArrowLeft size={17} /> error={readerError}
Fiche onRetry={error ? () => void loadBook() : undefined}
</button> controls={readerControls}
<div> zoomControls={zoomControls}
<strong>{book.title}</strong> modeControls={modeControls}
<span>{saving ? "Sauvegarde" : "Progression synchronisee"}</span> >
</div> <ReaderCrashBoundary
<Save size={18} /> resetKey={`${bookId}:${book?.format ?? "loading"}:${mode}`}
</header> onError={() => setReaderControls(idleControls)}
<Meter value={progress?.percent ?? 0} /> fallbackRender={(crashError, retry) => (
{book.format === "pdf" ? ( <div className="reader-error" role="alert">
<PdfReader url={fileUrl} page={page} onPageChange={savePdfPage} /> <div>
) : ( <h2>Le lecteur a rencontré une erreur.</h2>
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} /> <p>La page reste ouverte. Vous pouvez réessayer, revenir au mode page par page ou retourner à la fiche du livre.</p>
)} </div>
</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 { FormEvent, useEffect, useState } from "react";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import type { BookDto } from "@readabook/shared"; import type { BookDto, JobDto } from "@readabook/shared";
import { api } from "../api/client"; import { api, getApiFallback } from "../api/client";
import { hasActiveCoverWork, isBookCoverUpdating } from "../api/types";
import { BookCard } from "../components/BookCard"; import { BookCard } from "../components/BookCard";
import { EmptyState, LoadingState, Panel } from "../components/ui"; import { EmptyState, LoadingState, Panel } from "../components/ui";
export function SearchPage() { export function SearchPage() {
const [query, setQuery] = useState(""); 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(() => { useEffect(() => {
api.books().then(setBooks); void loadBooks("");
}, []); }, []);
async function submit(event: FormEvent) { async function submit(event: FormEvent) {
event.preventDefault(); event.preventDefault();
setBooks(null); await loadBooks(query);
setBooks(query.trim() ? await api.search(query.trim()) : await api.books());
} }
return ( return (
@ -29,18 +50,29 @@ export function SearchPage() {
Chercher Chercher
</button> </button>
</form> </form>
{error && (
<div className="retry-row">
<span>{error}</span>
<button className="ghost-button" onClick={() => void loadBooks(query)}>
Reessayer
</button>
</div>
)}
</Panel> </Panel>
{!books ? ( {loading && !books.length ? (
<LoadingState /> <LoadingState />
) : books.length ? ( ) : books.length ? (
<section className="book-grid span-3"> <section className="book-grid span-3">
{books.map((book) => ( {books.map((book) => (
<BookCard key={book.id} book={book} /> <BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
))} ))}
</section> </section>
) : ( ) : (
<Panel className="span-3"> <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> </Panel>
)} )}
</div> </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 { useEffect, useRef, useState } from "react";
import { ReaderError, readerErrorMessage } from "./ReaderError";
import type { ReaderControls } from "./ReaderShell";
import type { ReaderMode } from "../api/types";
type FoliateModule = { type FoliateLocation = {
EPUB?: unknown; cfi?: string;
default?: unknown; 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 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
let view: FoliateView | null = null;
async function mount() { async function mount() {
try { 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; if (cancelled || !hostRef.current) return;
hostRef.current.dataset.engine = module.EPUB || module.default ? "foliate-js" : "fallback";
setStatus("EPUB pret"); const response = await fetch(url, { credentials: "include" });
onLocatorChange(locator ?? "epub:start", locator ? 35 : 1); if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
} catch { const blob = await response.blob();
setStatus("Apercu EPUB indisponible dans ce navigateur"); 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 () => { return () => {
cancelled = true; 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 ( return (
<div className="epub-reader" ref={hostRef}> <div className={`epub-reader epub-reader-${mode}`}>
<iframe title="EPUB" src={url} /> {loading && (
<div className="reader-fallback">{status}</div> <div className="reader-fallback">
<span>Ouverture EPUB</span>
</div>
)}
<div className="epub-host" ref={hostRef} />
</div> </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 * 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 canvasRef = useRef<HTMLCanvasElement>(null);
const [pages, setPages] = useState(1); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>(); const [rendered, setRendered] = useState(false);
const [pageStatus, setPageStatus] = useState<PdfPageStatus>("idle");
const [pageError, setPageError] = useState<string>();
const showCanvas = active && pdfCanvasVisible(rendered, Boolean(pageError));
useEffect(() => { useEffect(() => {
if (!active || !viewportSize) return;
const readerViewport = viewportSize;
let cancelled = false; let cancelled = false;
async function render() { let renderTask: pdfjs.RenderTask | undefined;
setLoading(true);
setRendered(false);
setPageStatus("loading");
setPageError(undefined);
async function renderPage() {
try { try {
const loadingTask = pdfjs.getDocument({ url, withCredentials: true }); const pdfPage = await documentProxy.getPage(pageNumber);
const document = await loadingTask.promise;
if (cancelled) return; if (cancelled) return;
setPages(document.numPages);
const pdfPage = await document.getPage(Math.max(1, Math.min(page, document.numPages)));
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const viewport = pdfPage.getViewport({ scale: Math.min(1.6, window.devicePixelRatio || 1) }); const baseViewport = pdfPage.getViewport({ scale: 1 });
canvas.width = viewport.width; const fitScale = pdfRenderScale(readerViewport, { width: baseViewport.width, height: baseViewport.height });
canvas.height = viewport.height; const pixelRatio = Math.min(2, window.devicePixelRatio || 1);
const context = canvas.getContext("2d"); const viewport = pdfPage.getViewport({ scale: Math.max(0.25, fitScale * readerZoomFactor(zoom)) * pixelRatio });
if (!context) return; const cssWidth = Math.floor(viewport.width / pixelRatio);
await pdfPage.render({ canvas, canvasContext: context, viewport }).promise; const cssHeight = Math.floor(viewport.height / pixelRatio);
onPageChange(Math.max(1, Math.min(page, document.numPages)), document.numPages); canvas.width = Math.max(1, Math.floor(viewport.width));
} catch (renderError) { canvas.height = Math.max(1, Math.floor(viewport.height));
setError(renderError instanceof Error ? renderError.message : "PDF indisponible"); 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 () => { return () => {
cancelled = true; cancelled = true;
renderTask?.cancel();
}; };
}, [url, page, onPageChange]); }, [active, documentProxy, pageNumber, viewportSize, zoom]);
return ( return (
<div className="pdf-reader"> <div className="pdf-page-frame pdf-page-frame-vertical" data-reader-page={pageNumber}>
{error ? <div className="reader-fallback">{error}</div> : <canvas ref={canvasRef} />} {(!active || !viewportSize || loading || !rendered || pageError) && (
<div className="reader-stepper"> <div className={`reader-fallback${pageError ? " reader-fallback-error" : ""}`}>
<button className="ghost-button" onClick={() => onPageChange(Math.max(1, page - 1), pages)}> <span>{pageError ?? `Page ${pageNumber}`}</span>
Precedent </div>
</button> )}
<span> <canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
{page} / {pages} </div>
</span> );
<button className="ghost-button" onClick={() => onPageChange(Math.min(pages, page + 1), pages)}> }
Suivant
</button> export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onControlsChange }: PdfReaderProps) {
</div> 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> </div>
); );
} }

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