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:
10
.env.example
Normal file
10
.env.example
Normal 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
3
.gitignore
vendored
@ -5,7 +5,8 @@ dist
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
*.tsbuildinfo
|
||||
data/storage
|
||||
data/
|
||||
Books/
|
||||
coverage
|
||||
.pnpm-store
|
||||
.ideai/
|
||||
|
||||
24
README.md
24
README.md
@ -2,6 +2,16 @@
|
||||
|
||||
ReadaBook est une application locale-first pour cataloguer, rechercher et lire une bibliothèque personnelle de livres EPUB/PDF stockés sur disque. Le MVP livré vise un usage domestique : un administrateur déclare un dossier local, lance un scan, puis les livres deviennent accessibles via un catalogue web et une API locale.
|
||||
|
||||
## Dépôt distant
|
||||
|
||||
Le dépôt est hébergé sur un Gitea self-hosted et peut être cloné via :
|
||||
|
||||
```bash
|
||||
git clone https://gitea.anthonybouteiller.ovh/blomios/ReadaBook.git
|
||||
```
|
||||
|
||||
Le déploiement git suit un git-flow simplifié : `main` (releases), `develop` (intégration), `feature/*` / `fix/*` (travail en cours).
|
||||
|
||||
## Fonctionnalités MVP présentes
|
||||
|
||||
- Backend NestJS/Fastify exécutable avec healthcheck `GET /healthz`.
|
||||
@ -94,12 +104,14 @@ La PWA fournit `manifest.webmanifest`, `sw.js`, une icône maskable SVG et `disp
|
||||
Volumes :
|
||||
|
||||
- `./data:/data` : base SQLite `/data/readabook.sqlite` et cache `/data/storage`.
|
||||
- `./data/library:/library:ro` : bibliothèque locale scannée en lecture seule.
|
||||
- `${READABOOK_LIBRARY_HOST_PATH:-./data/library}:/library:ro` : bibliothèque locale scannée en lecture seule.
|
||||
|
||||
Variables principales :
|
||||
|
||||
- `JWT_SECRET` : secret JWT, à changer hors développement.
|
||||
- `OPEN_LIBRARY_ENABLED=true|false` : active/désactive l’enrichissement distant.
|
||||
- `READABOOK_LIBRARY_HOST_PATH` : dossier hôte monté en lecture seule sur `/library`.
|
||||
- `READABOOK_LIBRARY_ALIAS_FROM` : chemin alternatif accepté par l’API et traduit vers `/library`.
|
||||
- `DATABASE_PATH=/data/readabook.sqlite`
|
||||
- `STORAGE_DIR=/data/storage`
|
||||
|
||||
@ -134,6 +146,16 @@ curl -b cookies.txt \
|
||||
http://localhost:3000/admin/libraries
|
||||
```
|
||||
|
||||
Pour tester le corpus réel local `Books/` sans le versionner, crée un `.env` local :
|
||||
|
||||
```bash
|
||||
READABOOK_LIBRARY_HOST_PATH=./Books
|
||||
READABOOK_LIBRARY_ALIAS_FROM=/chemin/absolu/vers/ReadaBook/Books
|
||||
```
|
||||
|
||||
QA peut ensuite créer la bibliothèque avec le chemin absolu saisi dans `READABOOK_LIBRARY_ALIAS_FROM`;
|
||||
l’API le traduit vers `/library`, puis le scan manuel teste l’extraction ISBN/métadonnées/jaquettes sur ce corpus.
|
||||
|
||||
## Lancer un scan
|
||||
|
||||
```bash
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
"fastify": "^5.2.1",
|
||||
"jose": "^5.9.6",
|
||||
"mime-types": "^2.1.35",
|
||||
"node-unrar-js": "^2.0.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"zod": "^3.24.2"
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
CreateLibraryDto,
|
||||
CreateLibrarySchema,
|
||||
CreateUserDto,
|
||||
CreateUserSchema,
|
||||
UpdateAutomationSettingsDto,
|
||||
UpdateAutomationSettingsSchema,
|
||||
UpdateMetadataSourcesConfigDto,
|
||||
UpdateMetadataSourcesConfigSchema,
|
||||
UpdateLibraryDto,
|
||||
UpdateLibrarySchema,
|
||||
UpdateUserDto,
|
||||
@ -13,9 +17,11 @@ import { AuthGuard } from "../auth/auth.guard.js";
|
||||
import { Roles } from "../auth/roles.decorator.js";
|
||||
import { RolesGuard } from "../auth/roles.guard.js";
|
||||
import { AuthService } from "../auth/auth.service.js";
|
||||
import { AutomationService } from "../automation/automation.service.js";
|
||||
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
|
||||
import { JobsService } from "../jobs/jobs.service.js";
|
||||
import { LibrariesService } from "../libraries/libraries.service.js";
|
||||
import { MetadataService } from "../metadata/metadata.service.js";
|
||||
import { ScannerService } from "../scanner/scanner.service.js";
|
||||
|
||||
@Controller("admin")
|
||||
@ -26,7 +32,9 @@ export class AdminController {
|
||||
private readonly auth: AuthService,
|
||||
private readonly libraries: LibrariesService,
|
||||
private readonly jobs: JobsService,
|
||||
private readonly scanner: ScannerService
|
||||
private readonly scanner: ScannerService,
|
||||
private readonly metadata: MetadataService,
|
||||
private readonly automation: AutomationService
|
||||
) {}
|
||||
|
||||
@Get("users")
|
||||
@ -80,4 +88,34 @@ export class AdminController {
|
||||
listJobs() {
|
||||
return this.jobs.list();
|
||||
}
|
||||
|
||||
@Get("metadata-sources")
|
||||
metadataSources() {
|
||||
return this.metadata.getSourcesConfig();
|
||||
}
|
||||
|
||||
@Put("metadata-sources")
|
||||
updateMetadataSources(@Body(new ZodValidationPipe(UpdateMetadataSourcesConfigSchema)) body: UpdateMetadataSourcesConfigDto) {
|
||||
return this.metadata.updateSourcesConfig(body);
|
||||
}
|
||||
|
||||
@Get("automation")
|
||||
automationSettings() {
|
||||
return this.automation.getSettings();
|
||||
}
|
||||
|
||||
@Put("automation")
|
||||
updateAutomationSettings(@Body(new ZodValidationPipe(UpdateAutomationSettingsSchema)) body: UpdateAutomationSettingsDto) {
|
||||
return this.automation.updateSettings(body);
|
||||
}
|
||||
|
||||
@Post("automation/run-scan")
|
||||
runAutomationScan() {
|
||||
return this.automation.runScanNow();
|
||||
}
|
||||
|
||||
@Post("automation/run-enrich")
|
||||
runAutomationEnrich() {
|
||||
return this.automation.runEnrichNow();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { AutomationModule } from "../automation/automation.module.js";
|
||||
import { DatabaseModule } from "../database/database.module.js";
|
||||
import { JobsModule } from "../jobs/jobs.module.js";
|
||||
import { LibrariesService } from "../libraries/libraries.service.js";
|
||||
import { MetadataModule } from "../metadata/metadata.module.js";
|
||||
import { ScannerModule } from "../scanner/scanner.module.js";
|
||||
import { AdminController } from "./admin.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, DatabaseModule, JobsModule, ScannerModule],
|
||||
imports: [AuthModule, DatabaseModule, JobsModule, ScannerModule, MetadataModule, AutomationModule],
|
||||
controllers: [AdminController],
|
||||
providers: [LibrariesService]
|
||||
})
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AdminModule } from "./admin/admin.module.js";
|
||||
import { AutomationModule } from "./automation/automation.module.js";
|
||||
import { AuthModule } from "./auth/auth.module.js";
|
||||
import { BooksModule } from "./books/books.module.js";
|
||||
import { DatabaseModule } from "./database/database.module.js";
|
||||
import { ProgressModule } from "./progress/progress.module.js";
|
||||
import { ReaderModule } from "./reader/reader.module.js";
|
||||
import { ScannerModule } from "./scanner/scanner.module.js";
|
||||
import { HealthController } from "./health.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ScannerModule],
|
||||
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ScannerModule, AutomationModule],
|
||||
controllers: [HealthController]
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import { Body, Controller, Get, Post, Res, UseGuards } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Patch, Post, Res, UseGuards } from "@nestjs/common";
|
||||
import "@fastify/cookie";
|
||||
import { FastifyReply } from "fastify";
|
||||
import { BootstrapAdminDto, BootstrapAdminSchema, LoginDto, LoginSchema } from "@readabook/shared";
|
||||
import {
|
||||
BootstrapAdminDto,
|
||||
BootstrapAdminSchema,
|
||||
LoginDto,
|
||||
LoginSchema,
|
||||
UpdateAccountDto,
|
||||
UpdateAccountSchema
|
||||
} from "@readabook/shared";
|
||||
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
|
||||
import { AuthGuard } from "./auth.guard.js";
|
||||
import { AuthService } from "./auth.service.js";
|
||||
@ -11,6 +18,11 @@ import { CurrentUser, CurrentUserParam } from "./current-user.js";
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Get("status")
|
||||
async status() {
|
||||
return this.auth.status();
|
||||
}
|
||||
|
||||
@Post("bootstrap")
|
||||
async bootstrap(@Body(new ZodValidationPipe(BootstrapAdminSchema)) body: BootstrapAdminDto) {
|
||||
return this.auth.bootstrapAdmin(body);
|
||||
@ -40,4 +52,13 @@ export class AuthController {
|
||||
me(@CurrentUserParam() user: CurrentUser) {
|
||||
return { user };
|
||||
}
|
||||
|
||||
@Patch("me")
|
||||
@UseGuards(AuthGuard)
|
||||
updateMe(
|
||||
@CurrentUserParam() user: CurrentUser,
|
||||
@Body(new ZodValidationPipe(UpdateAccountSchema)) body: UpdateAccountDto
|
||||
) {
|
||||
return this.auth.updateOwnAccount(user.id, body);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { ConflictException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConflictException, Injectable, OnModuleInit, UnauthorizedException } from "@nestjs/common";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import argon2 from "argon2";
|
||||
import { BootstrapAdminDto, CreateUserDto, LoginDto, UpdateUserDto } from "@readabook/shared";
|
||||
import { BootstrapAdminDto, CreateUserDto, LoginDto, UpdateAccountDto, UpdateUserDto } from "@readabook/shared";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { users } from "../database/schema.js";
|
||||
import { CurrentUser } from "./current-user.js";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
export class AuthService implements OnModuleInit {
|
||||
readonly cookieName: string;
|
||||
private readonly secret: Uint8Array;
|
||||
|
||||
@ -17,6 +17,10 @@ export class AuthService {
|
||||
this.secret = new TextEncoder().encode(database.config.jwtSecret);
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.ensureInitialAdmin();
|
||||
}
|
||||
|
||||
get cookieSecure(): boolean {
|
||||
return this.database.config.cookieSecure;
|
||||
}
|
||||
@ -29,6 +33,26 @@ export class AuthService {
|
||||
return this.createUser({ ...input, role: "admin" });
|
||||
}
|
||||
|
||||
async status() {
|
||||
const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get();
|
||||
const initialAdmin = this.database.db
|
||||
.select({ passwordHash: users.passwordHash, role: users.role })
|
||||
.from(users)
|
||||
.where(eq(users.email, this.database.config.initialAdminEmail.toLowerCase()))
|
||||
.get();
|
||||
const initialAdminPasswordIsDefault =
|
||||
Boolean(initialAdmin) &&
|
||||
initialAdmin?.role === "admin" &&
|
||||
this.database.config.initialAdminPasswordIsDefault &&
|
||||
(await argon2.verify(initialAdmin.passwordHash, this.database.config.initialAdminPassword));
|
||||
|
||||
return {
|
||||
hasUsers: Boolean(existing),
|
||||
initialAdminEmail: this.database.config.initialAdminEmail.toLowerCase(),
|
||||
initialAdminPasswordIsDefault
|
||||
};
|
||||
}
|
||||
|
||||
async login(input: LoginDto): Promise<{ token: string; user: CurrentUser }> {
|
||||
const user = this.database.db.select().from(users).where(eq(users.email, input.email.toLowerCase())).get();
|
||||
if (!user || !(await argon2.verify(user.passwordHash, input.password))) {
|
||||
@ -65,6 +89,35 @@ export class AuthService {
|
||||
.all();
|
||||
}
|
||||
|
||||
async updateOwnAccount(id: number, input: UpdateAccountDto) {
|
||||
const user = this.database.db.select().from(users).where(eq(users.id, id)).get();
|
||||
if (!user || !(await argon2.verify(user.passwordHash, input.currentPassword))) {
|
||||
throw new UnauthorizedException("Current password is invalid");
|
||||
}
|
||||
|
||||
const values: Partial<typeof users.$inferInsert> = { updatedAt: this.database.now() };
|
||||
if (input.email) values.email = input.email.toLowerCase();
|
||||
if (input.name !== undefined) values.name = input.name;
|
||||
if (input.newPassword) values.passwordHash = await argon2.hash(input.newPassword);
|
||||
|
||||
try {
|
||||
return this.database.db
|
||||
.update(users)
|
||||
.set(values)
|
||||
.where(eq(users.id, id))
|
||||
.returning({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt
|
||||
})
|
||||
.get();
|
||||
} catch {
|
||||
throw new ConflictException("Email already exists");
|
||||
}
|
||||
}
|
||||
|
||||
async createUser(input: CreateUserDto) {
|
||||
const now = this.database.now();
|
||||
const passwordHash = await argon2.hash(input.password);
|
||||
@ -125,4 +178,22 @@ export class AuthService {
|
||||
.setExpirationTime("7d")
|
||||
.sign(this.secret);
|
||||
}
|
||||
|
||||
private async ensureInitialAdmin(): Promise<void> {
|
||||
const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get();
|
||||
if (existing) return;
|
||||
|
||||
const now = this.database.now();
|
||||
this.database.db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: this.database.config.initialAdminEmail.toLowerCase(),
|
||||
name: "Initial administrator",
|
||||
passwordHash: await argon2.hash(this.database.config.initialAdminPassword),
|
||||
role: "admin",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
11
apps/api/src/automation/automation.module.ts
Normal file
11
apps/api/src/automation/automation.module.ts
Normal 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 {}
|
||||
14
apps/api/src/automation/automation.service.test.ts
Normal file
14
apps/api/src/automation/automation.service.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
209
apps/api/src/automation/automation.service.ts
Normal file
209
apps/api/src/automation/automation.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Param, Query, Res, UseGuards } from "@nestjs/common";
|
||||
import { Controller, Get, Headers, Param, Query, Res, UseGuards } from "@nestjs/common";
|
||||
import { FastifyReply } from "fastify";
|
||||
import { lookup } from "mime-types";
|
||||
import { BookQueryDto, BookQuerySchema } from "@readabook/shared";
|
||||
@ -26,11 +26,31 @@ export class BooksController {
|
||||
return this.books.get(Number(id));
|
||||
}
|
||||
|
||||
@Get(":id/pages")
|
||||
pages(@Param("id") id: string) {
|
||||
return this.books.listComicPages(Number(id));
|
||||
}
|
||||
|
||||
@Get(":id/pages/:page")
|
||||
async page(@Param("id") id: string, @Param("page") page: string, @Res() reply: FastifyReply) {
|
||||
const result = await this.books.readComicPage(Number(id), Number(page));
|
||||
reply.header("Content-Type", result.contentType);
|
||||
reply.header("Cache-Control", "private, max-age=3600");
|
||||
return reply.send(result.data);
|
||||
}
|
||||
|
||||
@Get(":id/file")
|
||||
file(@Param("id") id: string, @Res() reply: FastifyReply) {
|
||||
const { book, stream } = this.books.streamFile(Number(id));
|
||||
reply.header("Content-Type", lookup(book.filePath) || "application/octet-stream");
|
||||
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(book.title)}.${book.format}"`);
|
||||
file(@Param("id") id: string, @Headers("range") range: string | undefined, @Res() reply: FastifyReply) {
|
||||
const { book, stream, contentLength, contentType, end, partial, size, start } = this.books.streamFile(Number(id), range);
|
||||
if (partial) {
|
||||
reply.code(206);
|
||||
reply.header("Content-Range", `bytes ${start}-${end}/${size}`);
|
||||
}
|
||||
reply.header("Accept-Ranges", "bytes");
|
||||
reply.header("Content-Length", String(contentLength));
|
||||
reply.header("Content-Type", contentType || lookup(book.filePath) || "application/octet-stream");
|
||||
reply.header("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(`${book.title}.${book.format}`)}`);
|
||||
reply.header("X-Content-Type-Options", "nosniff");
|
||||
return reply.send(stream);
|
||||
}
|
||||
|
||||
|
||||
@ -3,10 +3,11 @@ import { AuthModule } from "../auth/auth.module.js";
|
||||
import { DatabaseModule } from "../database/database.module.js";
|
||||
import { BooksController } from "./books.controller.js";
|
||||
import { BooksService } from "./books.service.js";
|
||||
import { SeriesController } from "./series.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, DatabaseModule],
|
||||
controllers: [BooksController],
|
||||
controllers: [BooksController, SeriesController],
|
||||
providers: [BooksService],
|
||||
exports: [BooksService]
|
||||
})
|
||||
|
||||
264
apps/api/src/books/books.service.test.ts
Normal file
264
apps/api/src/books/books.service.test.ts
Normal 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";
|
||||
}
|
||||
@ -1,9 +1,13 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { BadRequestException, HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||
import { extname } from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { BookQueryDto } from "@readabook/shared";
|
||||
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
|
||||
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { books } from "../database/schema.js";
|
||||
import { books, series } from "../database/schema.js";
|
||||
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
|
||||
|
||||
@Injectable()
|
||||
export class BooksService {
|
||||
@ -16,17 +20,33 @@ export class BooksService {
|
||||
if (query.q) {
|
||||
return this.search(query.q, query.limit, query.offset);
|
||||
}
|
||||
return this.database.db
|
||||
const statement = this.database.db
|
||||
.select()
|
||||
.from(books)
|
||||
.where(filters.length ? and(...filters) : undefined)
|
||||
.orderBy(books.title)
|
||||
.limit(query.limit)
|
||||
.offset(query.offset)
|
||||
.all();
|
||||
.orderBy(books.title);
|
||||
const rows =
|
||||
query.limit === undefined ? statement.all() : statement.limit(query.limit).offset(query.offset).all();
|
||||
return rows
|
||||
.map((book) => this.mapBookSelect(book));
|
||||
}
|
||||
|
||||
search(q: string, limit = 50, offset = 0) {
|
||||
search(q: string, limit?: number, offset = 0) {
|
||||
if (limit === undefined) {
|
||||
const rows = this.database.sqlite
|
||||
.prepare(
|
||||
`
|
||||
SELECT books.*
|
||||
FROM book_fts
|
||||
JOIN books ON books.id = book_fts.rowid
|
||||
WHERE book_fts MATCH ?
|
||||
ORDER BY bm25(book_fts)
|
||||
`
|
||||
)
|
||||
.all(`${q.replace(/"/g, '""')}*`);
|
||||
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
|
||||
}
|
||||
|
||||
const rows = this.database.sqlite
|
||||
.prepare(
|
||||
`
|
||||
@ -39,10 +59,86 @@ export class BooksService {
|
||||
`
|
||||
)
|
||||
.all(`${q.replace(/"/g, '""')}*`, limit, offset);
|
||||
return (rows as Array<Record<string, unknown>>).map(mapBookRow);
|
||||
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
|
||||
}
|
||||
|
||||
get(id: number) {
|
||||
return this.mapBookSelect(this.getRecord(id));
|
||||
}
|
||||
|
||||
listSeries() {
|
||||
return this.database.db.select().from(series).orderBy(series.title).all();
|
||||
}
|
||||
|
||||
getSeries(id: number) {
|
||||
const row = this.database.db.select().from(series).where(eq(series.id, id)).get();
|
||||
if (!row) throw new NotFoundException("Series not found");
|
||||
const seriesBooks = this.database.db
|
||||
.select()
|
||||
.from(books)
|
||||
.where(eq(books.seriesId, id))
|
||||
.orderBy(books.volumeNumber, books.title)
|
||||
.all()
|
||||
.map((book) => this.mapBookSelect(book));
|
||||
return { ...row, books: seriesBooks };
|
||||
}
|
||||
|
||||
streamFile(id: number, range?: string) {
|
||||
const book = this.getRecord(id);
|
||||
if (!existsSync(book.filePath)) {
|
||||
throw new NotFoundException("Book file not found on disk");
|
||||
}
|
||||
const size = statSync(book.filePath).size;
|
||||
const byteRange = parseByteRange(range, size);
|
||||
const stream = createReadStream(book.filePath, { start: byteRange.start, end: byteRange.end });
|
||||
return {
|
||||
book,
|
||||
stream,
|
||||
contentType: bookContentType(book.format),
|
||||
contentLength: byteRange.end - byteRange.start + 1,
|
||||
size,
|
||||
...byteRange
|
||||
};
|
||||
}
|
||||
|
||||
streamCover(id: number) {
|
||||
const book = this.getRecord(id);
|
||||
if (!book.coverPath || !existsSync(book.coverPath)) {
|
||||
throw new NotFoundException("Cover not found");
|
||||
}
|
||||
return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath };
|
||||
}
|
||||
|
||||
async listComicPages(id: number) {
|
||||
const book = this.getRecord(id);
|
||||
this.assertComicArchiveBook(book);
|
||||
const pages = book.format === "cbr" ? await listCbrImageEntries(book.filePath) : listCbzImageEntries(book.filePath);
|
||||
return {
|
||||
bookId: book.id,
|
||||
pageCount: pages.length,
|
||||
pages: pages.map((page, index) => ({ page: index + 1, name: page.name }))
|
||||
};
|
||||
}
|
||||
|
||||
async readComicPage(id: number, page: number) {
|
||||
const book = this.getRecord(id);
|
||||
this.assertComicArchiveBook(book);
|
||||
try {
|
||||
const result =
|
||||
book.format === "cbr"
|
||||
? await readCbrPage(book.filePath, page, this.database.config.storageDir)
|
||||
: readCbzPage(book.filePath, page);
|
||||
return { book, page, contentType: lookupMime(result.entryName), data: result.data };
|
||||
} catch (error) {
|
||||
throw new NotFoundException(error instanceof Error ? error.message : "Comic page not found");
|
||||
}
|
||||
}
|
||||
|
||||
count() {
|
||||
return this.database.db.select({ count: sql<number>`count(*)` }).from(books).get()?.count ?? 0;
|
||||
}
|
||||
|
||||
private getRecord(id: number): typeof books.$inferSelect {
|
||||
const book = this.database.db.select().from(books).where(eq(books.id, id)).get();
|
||||
if (!book) {
|
||||
throw new NotFoundException("Book not found");
|
||||
@ -50,48 +146,121 @@ export class BooksService {
|
||||
return book;
|
||||
}
|
||||
|
||||
streamFile(id: number) {
|
||||
const book = this.get(id);
|
||||
private assertComicArchiveBook(book: typeof books.$inferSelect): void {
|
||||
if (book.format !== "cbz" && book.format !== "cbr") {
|
||||
throw new BadRequestException("Book is not a comic archive");
|
||||
}
|
||||
if (!existsSync(book.filePath)) {
|
||||
throw new NotFoundException("Book file not found on disk");
|
||||
}
|
||||
return { book, stream: createReadStream(book.filePath) };
|
||||
}
|
||||
|
||||
streamCover(id: number) {
|
||||
const book = this.get(id);
|
||||
if (!book.coverPath || !existsSync(book.coverPath)) {
|
||||
throw new NotFoundException("Cover not found");
|
||||
}
|
||||
return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath };
|
||||
private mapBookRow(row: Record<string, unknown>) {
|
||||
const seriesId = nullable(row.series_id);
|
||||
return {
|
||||
id: Number(row.id),
|
||||
libraryId: Number(row.library_id),
|
||||
seriesId: seriesId ? Number(seriesId) : null,
|
||||
title: String(row.title),
|
||||
author: nullable(row.author),
|
||||
description: nullable(row.description),
|
||||
isbn: nullable(row.isbn),
|
||||
isbn13: nullable(row.isbn13),
|
||||
language: nullable(row.language),
|
||||
publisher: nullable(row.publisher),
|
||||
publishedDate: normalizePublishedDate(nullable(row.published_date)),
|
||||
volumeNumber: row.volume_number === null || row.volume_number === undefined ? null : Number(row.volume_number),
|
||||
volumeLabel: nullable(row.volume_label),
|
||||
format: row.format,
|
||||
filePath: String(row.file_path),
|
||||
coverPath: nullable(row.cover_path),
|
||||
metadataStatus: metadataStatusValue(row.metadata_status),
|
||||
metadataProvenance: parseObject(row.metadata_provenance_json),
|
||||
series: seriesId ? this.getSeriesRecord(Number(seriesId)) : null,
|
||||
scanStatus: statusValue(row.scan_status),
|
||||
enrichmentStatus: statusValue(row.enrichment_status),
|
||||
fileSize: Number(row.file_size),
|
||||
fileMtime: String(row.file_mtime),
|
||||
createdAt: String(row.created_at),
|
||||
updatedAt: String(row.updated_at)
|
||||
};
|
||||
}
|
||||
|
||||
count() {
|
||||
return this.database.db.select({ count: sql<number>`count(*)` }).from(books).get()?.count ?? 0;
|
||||
private mapBookSelect(row: typeof books.$inferSelect) {
|
||||
const { metadataProvenanceJson: _metadataProvenanceJson, ...book } = row;
|
||||
return {
|
||||
...book,
|
||||
publishedDate: normalizePublishedDate(row.publishedDate),
|
||||
metadataProvenance: parseObject(row.metadataProvenanceJson),
|
||||
series: row.seriesId ? this.getSeriesRecord(row.seriesId) : null
|
||||
};
|
||||
}
|
||||
|
||||
private getSeriesRecord(id: number) {
|
||||
return this.database.db.select().from(series).where(eq(series.id, id)).get() ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapBookRow(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
libraryId: Number(row.library_id),
|
||||
title: String(row.title),
|
||||
author: nullable(row.author),
|
||||
description: nullable(row.description),
|
||||
isbn: nullable(row.isbn),
|
||||
language: nullable(row.language),
|
||||
publisher: nullable(row.publisher),
|
||||
publishedDate: nullable(row.published_date),
|
||||
format: row.format,
|
||||
filePath: String(row.file_path),
|
||||
coverPath: nullable(row.cover_path),
|
||||
fileSize: Number(row.file_size),
|
||||
fileMtime: String(row.file_mtime),
|
||||
createdAt: String(row.created_at),
|
||||
updatedAt: String(row.updated_at)
|
||||
};
|
||||
function parseByteRange(range: string | undefined, size: number): { start: number; end: number; partial: boolean } {
|
||||
if (!range) return { start: 0, end: Math.max(size - 1, 0), partial: false };
|
||||
const match = range.match(/^bytes=(\d*)-(\d*)$/);
|
||||
if (!match || size <= 0) {
|
||||
throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
|
||||
}
|
||||
|
||||
const [, rawStart, rawEnd] = match;
|
||||
let start: number;
|
||||
let end: number;
|
||||
if (!rawStart && rawEnd) {
|
||||
const suffixLength = Number(rawEnd);
|
||||
start = Math.max(size - suffixLength, 0);
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = Number(rawStart);
|
||||
end = rawEnd ? Number(rawEnd) : size - 1;
|
||||
}
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= size) {
|
||||
throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
|
||||
}
|
||||
return { start, end: Math.min(end, size - 1), partial: true };
|
||||
}
|
||||
|
||||
function bookContentType(format: string): string {
|
||||
if (format === "epub") return "application/epub+zip";
|
||||
if (format === "pdf") return "application/pdf";
|
||||
if (format === "cbz") return "application/vnd.comicbook+zip";
|
||||
if (format === "cbr") return "application/vnd.comicbook-rar";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function lookupMime(entryName: string): string {
|
||||
const extension = extname(entryName).toLowerCase();
|
||||
if (extension === ".png") return "image/png";
|
||||
if (extension === ".webp") return "image/webp";
|
||||
if (extension === ".gif") return "image/gif";
|
||||
if (extension === ".avif") return "image/avif";
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
function nullable(value: unknown): string | null {
|
||||
return value === null || value === undefined ? null : String(value);
|
||||
}
|
||||
|
||||
function statusValue(value: unknown): "idle" | "running" | "succeeded" | "failed" {
|
||||
return value === "running" || value === "succeeded" || value === "failed" ? value : "idle";
|
||||
}
|
||||
|
||||
function metadataStatusValue(value: unknown): "enriched" | "partial" | "none" {
|
||||
return value === "enriched" || value === "partial" ? value : "none";
|
||||
}
|
||||
|
||||
function parseObject(value: unknown): Record<string, string> {
|
||||
if (typeof value !== "string") return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
return Object.fromEntries(Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
19
apps/api/src/books/series.controller.ts
Normal file
19
apps/api/src/books/series.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
64
apps/api/src/common/cbr.ts
Normal file
64
apps/api/src/common/cbr.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
46
apps/api/src/common/cbz.ts
Normal file
46
apps/api/src/common/cbz.ts
Normal 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() };
|
||||
}
|
||||
@ -11,8 +11,15 @@ export type AppConfig = {
|
||||
cookieName: string;
|
||||
cookieSecure: boolean;
|
||||
openLibraryEnabled: boolean;
|
||||
libraryPathAliases: Array<{ from: string; to: string }>;
|
||||
initialAdminEmail: string;
|
||||
initialAdminPassword: string;
|
||||
initialAdminPasswordIsDefault: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_INITIAL_ADMIN_EMAIL = "admin@readabook.local";
|
||||
const DEFAULT_INITIAL_ADMIN_PASSWORD = "readabook-admin-change-me";
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
const databasePath = resolve(process.env.DATABASE_PATH ?? "./data/readabook.sqlite");
|
||||
const storageDir = resolve(process.env.STORAGE_DIR ?? "./data/storage");
|
||||
@ -29,6 +36,26 @@ export function loadConfig(): AppConfig {
|
||||
jwtSecret: process.env.JWT_SECRET ?? "dev-change-me-readabook",
|
||||
cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session",
|
||||
cookieSecure: process.env.COOKIE_SECURE === "true",
|
||||
openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false"
|
||||
openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false",
|
||||
libraryPathAliases: parseLibraryPathAliases(process.env.LIBRARY_PATH_ALIASES),
|
||||
initialAdminEmail: process.env.INITIAL_ADMIN_EMAIL ?? DEFAULT_INITIAL_ADMIN_EMAIL,
|
||||
initialAdminPassword: process.env.INITIAL_ADMIN_PASSWORD ?? DEFAULT_INITIAL_ADMIN_PASSWORD,
|
||||
initialAdminPasswordIsDefault: !process.env.INITIAL_ADMIN_PASSWORD
|
||||
};
|
||||
}
|
||||
|
||||
function parseLibraryPathAliases(value: string | undefined): Array<{ from: string; to: string }> {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(";")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
const separator = entry.indexOf("=");
|
||||
if (separator === -1) return null;
|
||||
const from = entry.slice(0, separator).trim();
|
||||
const to = entry.slice(separator + 1).trim();
|
||||
return from && to ? { from, to } : null;
|
||||
})
|
||||
.filter((entry): entry is { from: string; to: string } => Boolean(entry));
|
||||
}
|
||||
|
||||
243
apps/api/src/database/database.service.test.ts
Normal file
243
apps/api/src/database/database.service.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { Injectable, OnModuleDestroy } from "@nestjs/common";
|
||||
import Database from "better-sqlite3";
|
||||
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { AppConfig, loadConfig } from "../config/env.js";
|
||||
import { extractSeriesVolume } from "../metadata/use-cases/extract-series-volume.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
@Injectable()
|
||||
@ -49,19 +50,39 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS series (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
normalized_title TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
publisher TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS books (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
series_id INTEGER REFERENCES series(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
description TEXT,
|
||||
isbn TEXT,
|
||||
isbn13 TEXT,
|
||||
identifiers_json TEXT,
|
||||
local_metadata_json TEXT,
|
||||
language TEXT,
|
||||
publisher TEXT,
|
||||
published_date TEXT,
|
||||
format TEXT NOT NULL CHECK (format IN ('epub','pdf')),
|
||||
volume_number INTEGER,
|
||||
volume_label TEXT,
|
||||
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
cover_path TEXT,
|
||||
metadata_status TEXT NOT NULL DEFAULT 'none' CHECK (metadata_status IN ('enriched','partial','none')),
|
||||
metadata_provenance_json TEXT,
|
||||
scan_status TEXT NOT NULL DEFAULT 'idle' CHECK (scan_status IN ('idle','running','succeeded','failed')),
|
||||
enrichment_status TEXT NOT NULL DEFAULT 'idle' CHECK (enrichment_status IN ('idle','running','succeeded','failed')),
|
||||
file_size INTEGER NOT NULL,
|
||||
file_mtime TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
@ -89,6 +110,26 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_source_config (
|
||||
provider TEXT PRIMARY KEY CHECK (provider IN ('local','openlibrary','googlebooks','bnf','mangadex','comicvine')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
priority INTEGER NOT NULL,
|
||||
api_key TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS automation_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
watch_libraries INTEGER NOT NULL DEFAULT 0,
|
||||
auto_enrich_new_books INTEGER NOT NULL DEFAULT 1,
|
||||
isbn_priority_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
scan_schedule_json TEXT NOT NULL,
|
||||
enrich_schedule_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS book_fts USING fts5(
|
||||
title,
|
||||
author,
|
||||
@ -119,6 +160,333 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
VALUES (new.id, new.title, new.author, new.description, new.isbn);
|
||||
END;
|
||||
`);
|
||||
this.ensureBooksSupportsComicArchives();
|
||||
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
|
||||
this.ensureBooksMetadataColumns();
|
||||
this.ensureSeriesModel();
|
||||
this.ensureReaderPreferencesTable();
|
||||
this.ensureMetadataSourceConfigSupportsComicProviders();
|
||||
this.ensureMetadataSourceConfigColumns();
|
||||
this.ensureAutomationSettingsColumns();
|
||||
this.ensureMetadataDefaults();
|
||||
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
|
||||
}
|
||||
|
||||
private ensureBooksSupportsComicArchives(): void {
|
||||
const table = this.sqlite
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'books'")
|
||||
.get() as { sql?: string } | undefined;
|
||||
if (!table?.sql || (table.sql.includes("'cbz'") && table.sql.includes("'cbr'"))) return;
|
||||
|
||||
this.sqlite.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
PRAGMA legacy_alter_table = ON;
|
||||
|
||||
DROP TRIGGER IF EXISTS books_ai;
|
||||
DROP TRIGGER IF EXISTS books_ad;
|
||||
DROP TRIGGER IF EXISTS books_au;
|
||||
|
||||
BEGIN;
|
||||
ALTER TABLE books RENAME TO books_legacy_format;
|
||||
CREATE TABLE books (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
series_id INTEGER REFERENCES series(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
description TEXT,
|
||||
isbn TEXT,
|
||||
isbn13 TEXT,
|
||||
identifiers_json TEXT,
|
||||
local_metadata_json TEXT,
|
||||
language TEXT,
|
||||
publisher TEXT,
|
||||
published_date TEXT,
|
||||
volume_number INTEGER,
|
||||
volume_label TEXT,
|
||||
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
cover_path TEXT,
|
||||
metadata_status TEXT NOT NULL DEFAULT 'none' CHECK (metadata_status IN ('enriched','partial','none')),
|
||||
metadata_provenance_json TEXT,
|
||||
scan_status TEXT NOT NULL DEFAULT 'idle' CHECK (scan_status IN ('idle','running','succeeded','failed')),
|
||||
enrichment_status TEXT NOT NULL DEFAULT 'idle' CHECK (enrichment_status IN ('idle','running','succeeded','failed')),
|
||||
file_size INTEGER NOT NULL,
|
||||
file_mtime TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO books (
|
||||
id, library_id, title, author, description, isbn, isbn13, identifiers_json, local_metadata_json, language, publisher, published_date,
|
||||
volume_number, volume_label, format, file_path, cover_path, metadata_status, metadata_provenance_json, scan_status, enrichment_status, file_size, file_mtime, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, library_id, title, author, description, isbn, NULL, NULL, NULL, language, publisher, published_date,
|
||||
NULL, NULL,
|
||||
format, file_path, cover_path, CASE WHEN cover_path IS NOT NULL OR author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL THEN 'partial' ELSE 'none' END, NULL,
|
||||
'idle', 'idle', file_size, file_mtime, created_at, updated_at
|
||||
FROM books_legacy_format;
|
||||
DROP TABLE books_legacy_format;
|
||||
COMMIT;
|
||||
|
||||
PRAGMA legacy_alter_table = OFF;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS books_file_path_unique ON books(file_path);
|
||||
CREATE INDEX IF NOT EXISTS books_library_idx ON books(library_id);
|
||||
CREATE INDEX IF NOT EXISTS books_title_idx ON books(title);
|
||||
CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS books_ai AFTER INSERT ON books BEGIN
|
||||
INSERT INTO book_fts(rowid, title, author, description, isbn)
|
||||
VALUES (new.id, new.title, new.author, new.description, new.isbn);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS books_ad AFTER DELETE ON books BEGIN
|
||||
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
|
||||
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS books_au AFTER UPDATE ON books BEGIN
|
||||
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
|
||||
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
|
||||
INSERT INTO book_fts(rowid, title, author, description, isbn)
|
||||
VALUES (new.id, new.title, new.author, new.description, new.isbn);
|
||||
END;
|
||||
`);
|
||||
}
|
||||
|
||||
private ensureBooksMetadataColumns(): void {
|
||||
const columns = this.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>;
|
||||
const names = new Set(columns.map((column) => column.name));
|
||||
if (!names.has("isbn13")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN isbn13 TEXT");
|
||||
}
|
||||
if (!names.has("identifiers_json")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN identifiers_json TEXT");
|
||||
}
|
||||
if (!names.has("local_metadata_json")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN local_metadata_json TEXT");
|
||||
}
|
||||
if (!names.has("metadata_status")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN metadata_status TEXT NOT NULL DEFAULT 'none'");
|
||||
this.sqlite.exec(`
|
||||
UPDATE books
|
||||
SET metadata_status = CASE
|
||||
WHEN cover_path IS NOT NULL AND (author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL) THEN 'enriched'
|
||||
WHEN cover_path IS NOT NULL OR author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL THEN 'partial'
|
||||
ELSE 'none'
|
||||
END
|
||||
`);
|
||||
}
|
||||
if (!names.has("metadata_provenance_json")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN metadata_provenance_json TEXT");
|
||||
}
|
||||
if (!names.has("scan_status")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN scan_status TEXT NOT NULL DEFAULT 'idle'");
|
||||
}
|
||||
if (!names.has("enrichment_status")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN enrichment_status TEXT NOT NULL DEFAULT 'idle'");
|
||||
}
|
||||
if (!names.has("series_id")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN series_id INTEGER REFERENCES series(id) ON DELETE SET NULL");
|
||||
}
|
||||
if (!names.has("volume_number")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN volume_number INTEGER");
|
||||
}
|
||||
if (!names.has("volume_label")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN volume_label TEXT");
|
||||
}
|
||||
this.sqlite.exec(`
|
||||
UPDATE books
|
||||
SET published_date = NULL
|
||||
WHERE published_date IS NOT NULL
|
||||
AND (
|
||||
trim(published_date) = '0000'
|
||||
OR substr(trim(published_date), 1, 10) IN ('0001-01-01', '0101-01-01', '1970-01-01')
|
||||
OR CAST(substr(trim(published_date), 1, 4) AS INTEGER) < 1500
|
||||
OR CAST(substr(trim(published_date), 1, 4) AS INTEGER) > 2027
|
||||
)
|
||||
`);
|
||||
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13)");
|
||||
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_local_metadata_idx ON books(local_metadata_json)");
|
||||
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_series_idx ON books(series_id)");
|
||||
}
|
||||
|
||||
private ensureSeriesModel(): void {
|
||||
this.sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS series (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
normalized_title TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
publisher TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS series_normalized_title_unique ON series(normalized_title);
|
||||
CREATE INDEX IF NOT EXISTS books_series_idx ON books(series_id);
|
||||
`);
|
||||
this.backfillSeries();
|
||||
}
|
||||
|
||||
private backfillSeries(): void {
|
||||
const rows = this.sqlite.prepare("SELECT id, title, file_path, series_id FROM books WHERE series_id IS NULL OR volume_number IS NULL").all() as Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
file_path: string;
|
||||
series_id: number | null;
|
||||
}>;
|
||||
if (!rows.length) return;
|
||||
const now = this.now();
|
||||
const insertSeries = this.sqlite.prepare(`
|
||||
INSERT INTO series (title, normalized_title, description, publisher, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, NULL, ?, ?)
|
||||
ON CONFLICT(normalized_title) DO UPDATE SET title = excluded.title, updated_at = excluded.updated_at
|
||||
RETURNING id
|
||||
`);
|
||||
const updateBook = this.sqlite.prepare("UPDATE books SET series_id = ?, volume_number = ?, volume_label = ? WHERE id = ?");
|
||||
const transaction = this.sqlite.transaction(() => {
|
||||
for (const row of rows) {
|
||||
const parsed = extractSeriesVolume(row.title, row.file_path);
|
||||
if (row.series_id !== null && parsed.volumeNumber === null) continue;
|
||||
const seriesId =
|
||||
row.series_id ??
|
||||
(insertSeries.get(parsed.seriesTitle, parsed.normalizedSeriesTitle, now, now) as { id: number }).id;
|
||||
updateBook.run(seriesId, parsed.volumeNumber, parsed.volumeLabel, row.id);
|
||||
}
|
||||
});
|
||||
transaction();
|
||||
}
|
||||
|
||||
private ensureReaderPreferencesTable(): void {
|
||||
this.sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reader_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('paged','scrolled','horizontal','vertical')),
|
||||
fit TEXT CHECK (fit IN ('page','width','height','auto')),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, book_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS reader_preferences_user_idx ON reader_preferences(user_id);
|
||||
`);
|
||||
}
|
||||
|
||||
private ensureMetadataSourceConfigColumns(): void {
|
||||
const names = this.columnNames("metadata_source_config");
|
||||
const now = sqlString(this.now());
|
||||
if (!names.has("enabled")) {
|
||||
this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1");
|
||||
}
|
||||
if (!names.has("priority")) {
|
||||
this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN priority INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
if (!names.has("api_key")) {
|
||||
this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN api_key TEXT");
|
||||
}
|
||||
if (!names.has("created_at")) {
|
||||
this.sqlite.exec(`ALTER TABLE metadata_source_config ADD COLUMN created_at TEXT NOT NULL DEFAULT ${now}`);
|
||||
}
|
||||
if (!names.has("updated_at")) {
|
||||
this.sqlite.exec(`ALTER TABLE metadata_source_config ADD COLUMN updated_at TEXT NOT NULL DEFAULT ${now}`);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureMetadataSourceConfigSupportsComicProviders(): void {
|
||||
const table = this.sqlite
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'metadata_source_config'")
|
||||
.get() as { sql?: string } | undefined;
|
||||
if (!table?.sql || (table.sql.includes("'mangadex'") && table.sql.includes("'comicvine'"))) return;
|
||||
|
||||
this.sqlite.exec(`
|
||||
BEGIN;
|
||||
ALTER TABLE metadata_source_config RENAME TO metadata_source_config_legacy_provider;
|
||||
CREATE TABLE metadata_source_config (
|
||||
provider TEXT PRIMARY KEY CHECK (provider IN ('local','openlibrary','googlebooks','bnf','mangadex','comicvine')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
priority INTEGER NOT NULL,
|
||||
api_key TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO metadata_source_config (provider, enabled, priority, api_key, created_at, updated_at)
|
||||
SELECT provider, enabled, priority, api_key, created_at, updated_at
|
||||
FROM metadata_source_config_legacy_provider;
|
||||
DROP TABLE metadata_source_config_legacy_provider;
|
||||
COMMIT;
|
||||
`);
|
||||
}
|
||||
|
||||
private ensureAutomationSettingsColumns(): void {
|
||||
const names = this.columnNames("automation_settings");
|
||||
const now = sqlString(this.now());
|
||||
const disabledScan = sqlString(JSON.stringify({ frequency: "disabled", time: "03:00", dayOfWeek: 1 }));
|
||||
const disabledEnrich = sqlString(JSON.stringify({ frequency: "disabled", time: "04:00", dayOfWeek: 1 }));
|
||||
if (!names.has("watch_libraries")) {
|
||||
this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN watch_libraries INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
if (!names.has("auto_enrich_new_books")) {
|
||||
this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN auto_enrich_new_books INTEGER NOT NULL DEFAULT 1");
|
||||
}
|
||||
if (!names.has("isbn_priority_enabled")) {
|
||||
this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN isbn_priority_enabled INTEGER NOT NULL DEFAULT 1");
|
||||
}
|
||||
if (!names.has("scan_schedule_json")) {
|
||||
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN scan_schedule_json TEXT NOT NULL DEFAULT ${disabledScan}`);
|
||||
}
|
||||
if (!names.has("enrich_schedule_json")) {
|
||||
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN enrich_schedule_json TEXT NOT NULL DEFAULT ${disabledEnrich}`);
|
||||
}
|
||||
if (!names.has("created_at")) {
|
||||
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN created_at TEXT NOT NULL DEFAULT ${now}`);
|
||||
}
|
||||
if (!names.has("updated_at")) {
|
||||
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN updated_at TEXT NOT NULL DEFAULT ${now}`);
|
||||
}
|
||||
}
|
||||
|
||||
private columnNames(table: string): Set<string> {
|
||||
const columns = this.sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
return new Set(columns.map((column) => column.name));
|
||||
}
|
||||
|
||||
private ensureMetadataDefaults(): void {
|
||||
const now = this.now();
|
||||
const insertSource = this.sqlite.prepare(`
|
||||
INSERT INTO metadata_source_config (provider, enabled, priority, api_key, created_at, updated_at)
|
||||
VALUES (?, ?, ?, NULL, ?, ?)
|
||||
ON CONFLICT(provider) DO NOTHING
|
||||
`);
|
||||
insertSource.run("local", 1, 0, now, now);
|
||||
insertSource.run("openlibrary", this.config.openLibraryEnabled ? 1 : 0, 1, now, now);
|
||||
insertSource.run("googlebooks", 0, 2, now, now);
|
||||
insertSource.run("bnf", 0, 3, now, now);
|
||||
insertSource.run("mangadex", 1, 4, now, now);
|
||||
insertSource.run("comicvine", 0, 5, now, now);
|
||||
|
||||
this.sqlite
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO automation_settings (
|
||||
id, watch_libraries, auto_enrich_new_books, isbn_priority_enabled,
|
||||
scan_schedule_json, enrich_schedule_json, created_at, updated_at
|
||||
)
|
||||
VALUES (1, 0, 1, 1, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
`
|
||||
)
|
||||
.run(
|
||||
JSON.stringify({ frequency: "disabled", time: "03:00", dayOfWeek: 1 }),
|
||||
JSON.stringify({ frequency: "disabled", time: "04:00", dayOfWeek: 1 }),
|
||||
now,
|
||||
now
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sqlString(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
@ -23,6 +23,20 @@ export const libraries = sqliteTable("libraries", {
|
||||
updatedAt: text("updated_at").notNull()
|
||||
});
|
||||
|
||||
export const series = sqliteTable(
|
||||
"series",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
title: text("title").notNull(),
|
||||
normalizedTitle: text("normalized_title").notNull(),
|
||||
description: text("description"),
|
||||
publisher: text("publisher"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
},
|
||||
(table) => ({ normalizedTitleIdx: uniqueIndex("series_normalized_title_unique").on(table.normalizedTitle) })
|
||||
);
|
||||
|
||||
export const books = sqliteTable(
|
||||
"books",
|
||||
{
|
||||
@ -30,16 +44,26 @@ export const books = sqliteTable(
|
||||
libraryId: integer("library_id")
|
||||
.notNull()
|
||||
.references(() => libraries.id, { onDelete: "cascade" }),
|
||||
seriesId: integer("series_id").references(() => series.id, { onDelete: "set null" }),
|
||||
title: text("title").notNull(),
|
||||
author: text("author"),
|
||||
description: text("description"),
|
||||
isbn: text("isbn"),
|
||||
isbn13: text("isbn13"),
|
||||
identifiersJson: text("identifiers_json"),
|
||||
localMetadataJson: text("local_metadata_json"),
|
||||
language: text("language"),
|
||||
publisher: text("publisher"),
|
||||
publishedDate: text("published_date"),
|
||||
format: text("format", { enum: ["epub", "pdf"] }).notNull(),
|
||||
volumeNumber: integer("volume_number"),
|
||||
volumeLabel: text("volume_label"),
|
||||
format: text("format", { enum: ["epub", "pdf", "cbz", "cbr"] }).notNull(),
|
||||
filePath: text("file_path").notNull(),
|
||||
coverPath: text("cover_path"),
|
||||
metadataStatus: text("metadata_status", { enum: ["enriched", "partial", "none"] }).notNull().default("none"),
|
||||
metadataProvenanceJson: text("metadata_provenance_json"),
|
||||
scanStatus: text("scan_status", { enum: ["idle", "running", "succeeded", "failed"] }).notNull().default("idle"),
|
||||
enrichmentStatus: text("enrichment_status", { enum: ["idle", "running", "succeeded", "failed"] }).notNull().default("idle"),
|
||||
fileSize: integer("file_size").notNull(),
|
||||
fileMtime: text("file_mtime").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
@ -48,6 +72,24 @@ export const books = sqliteTable(
|
||||
(table) => ({ filePathIdx: uniqueIndex("books_file_path_unique").on(table.filePath) })
|
||||
);
|
||||
|
||||
export const readerPreferences = sqliteTable(
|
||||
"reader_preferences",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
bookId: integer("book_id")
|
||||
.notNull()
|
||||
.references(() => books.id, { onDelete: "cascade" }),
|
||||
mode: text("mode", { enum: ["paged", "scrolled", "horizontal", "vertical"] }).notNull(),
|
||||
fit: text("fit", { enum: ["page", "width", "height", "auto"] }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
},
|
||||
(table) => ({ userBookIdx: uniqueIndex("reader_preferences_user_book_unique").on(table.userId, table.bookId) })
|
||||
);
|
||||
|
||||
export const progress = sqliteTable(
|
||||
"progress",
|
||||
{
|
||||
@ -75,3 +117,23 @@ export const jobs = sqliteTable("jobs", {
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
});
|
||||
|
||||
export const metadataSourceConfig = sqliteTable("metadata_source_config", {
|
||||
provider: text("provider", { enum: ["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"] }).primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
priority: integer("priority").notNull(),
|
||||
apiKey: text("api_key"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
});
|
||||
|
||||
export const automationSettings = sqliteTable("automation_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
watchLibraries: integer("watch_libraries", { mode: "boolean" }).notNull().default(false),
|
||||
autoEnrichNewBooks: integer("auto_enrich_new_books", { mode: "boolean" }).notNull().default(true),
|
||||
isbnPriorityEnabled: integer("isbn_priority_enabled", { mode: "boolean" }).notNull().default(true),
|
||||
scanScheduleJson: text("scan_schedule_json").notNull(),
|
||||
enrichScheduleJson: text("enrich_schedule_json").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
});
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { Injectable, OnModuleInit } from "@nestjs/common";
|
||||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { jobs } from "../database/schema.js";
|
||||
|
||||
@Injectable()
|
||||
export class JobsService {
|
||||
export class JobsService implements OnModuleInit {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.failInterruptedJobs();
|
||||
}
|
||||
|
||||
create(type: string, detail?: string) {
|
||||
const now = this.database.now();
|
||||
return this.database.db
|
||||
@ -43,4 +47,16 @@ export class JobsService {
|
||||
list(limit = 50) {
|
||||
return this.database.db.select().from(jobs).orderBy(desc(jobs.createdAt)).limit(limit).all();
|
||||
}
|
||||
|
||||
private failInterruptedJobs(): void {
|
||||
this.database.db
|
||||
.update(jobs)
|
||||
.set({
|
||||
status: "failed",
|
||||
error: "Job interrupted before completion, most likely by API shutdown or restart",
|
||||
updatedAt: this.database.now()
|
||||
})
|
||||
.where(inArray(jobs.status, ["queued", "running"]))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { CreateLibraryDto, UpdateLibraryDto } from "@readabook/shared";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { libraries } from "../database/schema.js";
|
||||
import { LibraryPathValidationError, resolveLibraryPath } from "./library-path.js";
|
||||
|
||||
@Injectable()
|
||||
export class LibrariesService {
|
||||
@ -24,6 +23,7 @@ export class LibrariesService {
|
||||
|
||||
create(input: CreateLibraryDto) {
|
||||
const path = this.validatePath(input.path);
|
||||
this.ensurePathUnused(path);
|
||||
const now = this.database.now();
|
||||
return this.database.db
|
||||
.insert(libraries)
|
||||
@ -35,7 +35,10 @@ export class LibrariesService {
|
||||
update(id: number, input: UpdateLibraryDto) {
|
||||
const values: Partial<typeof libraries.$inferInsert> = { updatedAt: this.database.now() };
|
||||
if (input.name) values.name = input.name;
|
||||
if (input.path) values.path = this.validatePath(input.path);
|
||||
if (input.path) {
|
||||
values.path = this.validatePath(input.path);
|
||||
this.ensurePathUnused(values.path, id);
|
||||
}
|
||||
if (input.enabled !== undefined) values.enabled = input.enabled;
|
||||
const library = this.database.db.update(libraries).set(values).where(eq(libraries.id, id)).returning().get();
|
||||
if (!library) {
|
||||
@ -49,17 +52,29 @@ export class LibrariesService {
|
||||
}
|
||||
|
||||
private validatePath(input: string): string {
|
||||
const resolved = resolve(input);
|
||||
try {
|
||||
accessSync(resolved, constants.R_OK);
|
||||
const stats = statSync(resolved);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new BadRequestException("Library path must be a directory");
|
||||
}
|
||||
return realpathSync(resolved);
|
||||
return resolveLibraryPath(input, this.database.config.libraryPathAliases);
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException) throw error;
|
||||
throw new BadRequestException("Library path is not readable");
|
||||
if (error instanceof LibraryPathValidationError) {
|
||||
throw new BadRequestException({
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
path: error.path
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private ensurePathUnused(path: string, exceptId?: number): void {
|
||||
const where = exceptId === undefined ? eq(libraries.path, path) : and(eq(libraries.path, path), ne(libraries.id, exceptId));
|
||||
const existing = this.database.db.select({ id: libraries.id }).from(libraries).where(where).get();
|
||||
if (existing) {
|
||||
throw new ConflictException({
|
||||
code: "LIBRARY_PATH_ALREADY_USED",
|
||||
message: "Library path is already used",
|
||||
path
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
49
apps/api/src/libraries/library-path.test.ts
Normal file
49
apps/api/src/libraries/library-path.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
72
apps/api/src/libraries/library-path.ts
Normal file
72
apps/api/src/libraries/library-path.ts
Normal 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";
|
||||
}
|
||||
96
apps/api/src/metadata/adapters/bnf.provider.ts
Normal file
96
apps/api/src/metadata/adapters/bnf.provider.ts
Normal 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;
|
||||
}
|
||||
160
apps/api/src/metadata/adapters/comic-vine.provider.ts
Normal file
160
apps/api/src/metadata/adapters/comic-vine.provider.ts
Normal 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(/ /g, " ").replace(/&/g, "&").replace(/\s+/g, " ").trim() || null;
|
||||
}
|
||||
|
||||
function imageUrl(value: unknown): string | null {
|
||||
const image = objectValue(value);
|
||||
return stringValue(image.original_url) ?? stringValue(image.super_url) ?? stringValue(image.medium_url) ?? stringValue(image.small_url);
|
||||
}
|
||||
|
||||
function yearFromDate(value: string | null): string | null {
|
||||
return value?.match(/\b(1[5-9]\d{2}|20\d{2})\b/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function tokenOverlap(left: string, right: string): number {
|
||||
const leftTokens = new Set(normalizeTokens(left));
|
||||
const rightTokens = new Set(normalizeTokens(right));
|
||||
if (!leftTokens.size || !rightTokens.size) return 0;
|
||||
return [...leftTokens].filter((token) => rightTokens.has(token)).length / leftTokens.size;
|
||||
}
|
||||
|
||||
function normalizeTokens(value: string): string[] {
|
||||
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").split(" ").filter(Boolean);
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<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;
|
||||
}
|
||||
201
apps/api/src/metadata/adapters/google-books.provider.ts
Normal file
201
apps/api/src/metadata/adapters/google-books.provider.ts
Normal 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;
|
||||
}
|
||||
28
apps/api/src/metadata/adapters/local.provider.ts
Normal file
28
apps/api/src/metadata/adapters/local.provider.ts
Normal 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)
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
179
apps/api/src/metadata/adapters/mangadex.provider.ts
Normal file
179
apps/api/src/metadata/adapters/mangadex.provider.ts
Normal 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;
|
||||
}
|
||||
148
apps/api/src/metadata/adapters/open-library.provider.ts
Normal file
148
apps/api/src/metadata/adapters/open-library.provider.ts
Normal 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);
|
||||
}
|
||||
88
apps/api/src/metadata/adapters/provider-fetch.ts
Normal file
88
apps/api/src/metadata/adapters/provider-fetch.ts
Normal 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);
|
||||
}
|
||||
32
apps/api/src/metadata/extract-identifiers.test.ts
Normal file
32
apps/api/src/metadata/extract-identifiers.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
43
apps/api/src/metadata/extract-series-volume.test.ts
Normal file
43
apps/api/src/metadata/extract-series-volume.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
113
apps/api/src/metadata/local-metadata-hints.test.ts
Normal file
113
apps/api/src/metadata/local-metadata-hints.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
410
apps/api/src/metadata/metadata-providers.test.ts
Normal file
410
apps/api/src/metadata/metadata-providers.test.ts
Normal 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 & 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" } });
|
||||
}
|
||||
16
apps/api/src/metadata/metadata.module.ts
Normal file
16
apps/api/src/metadata/metadata.module.ts
Normal 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 {}
|
||||
923
apps/api/src/metadata/metadata.service.test.ts
Normal file
923
apps/api/src/metadata/metadata.service.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
627
apps/api/src/metadata/metadata.service.ts
Normal file
627
apps/api/src/metadata/metadata.service.ts
Normal 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);
|
||||
}
|
||||
65
apps/api/src/metadata/metadata.types.ts
Normal file
65
apps/api/src/metadata/metadata.types.ts
Normal 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[]>;
|
||||
}
|
||||
23
apps/api/src/metadata/normalize-published-date.test.ts
Normal file
23
apps/api/src/metadata/normalize-published-date.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
21
apps/api/src/metadata/resolve-provider-chain.test.ts
Normal file
21
apps/api/src/metadata/resolve-provider-chain.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
114
apps/api/src/metadata/use-cases/extract-identifiers.ts
Normal file
114
apps/api/src/metadata/use-cases/extract-identifiers.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
67
apps/api/src/metadata/use-cases/extract-series-volume.ts
Normal file
67
apps/api/src/metadata/use-cases/extract-series-volume.ts
Normal 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();
|
||||
}
|
||||
44
apps/api/src/metadata/use-cases/normalize-published-date.ts
Normal file
44
apps/api/src/metadata/use-cases/normalize-published-date.ts
Normal 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;
|
||||
}
|
||||
14
apps/api/src/metadata/use-cases/resolve-provider-chain.ts
Normal file
14
apps/api/src/metadata/use-cases/resolve-provider-chain.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
127
apps/api/src/metadata/use-cases/score-metadata-match.ts
Normal file
127
apps/api/src/metadata/use-cases/score-metadata-match.ts
Normal 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;
|
||||
}
|
||||
@ -17,7 +17,7 @@ export class ProgressController {
|
||||
|
||||
@Get(":bookId")
|
||||
get(@CurrentUserParam() user: CurrentUser, @Param("bookId") bookId: string) {
|
||||
return this.progress.get(user.id, Number(bookId));
|
||||
return this.progress.find(user.id, Number(bookId));
|
||||
}
|
||||
|
||||
@Put(":bookId")
|
||||
|
||||
@ -30,6 +30,14 @@ export class ProgressService {
|
||||
}
|
||||
|
||||
get(userId: number, bookId: number) {
|
||||
const row = this.find(userId, bookId);
|
||||
if (!row) {
|
||||
throw new NotFoundException("Progress not found");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
find(userId: number, bookId: number) {
|
||||
const row = this.database.db
|
||||
.select({
|
||||
bookId: progress.bookId,
|
||||
@ -40,10 +48,7 @@ export class ProgressService {
|
||||
.from(progress)
|
||||
.where(sql`${progress.userId} = ${userId} AND ${progress.bookId} = ${bookId}`)
|
||||
.get();
|
||||
if (!row) {
|
||||
throw new NotFoundException("Progress not found");
|
||||
}
|
||||
return row;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
continueReading(userId: number) {
|
||||
|
||||
26
apps/api/src/reader/reader-preferences.controller.ts
Normal file
26
apps/api/src/reader/reader-preferences.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
47
apps/api/src/reader/reader-preferences.service.ts
Normal file
47
apps/api/src/reader/reader-preferences.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/reader/reader.module.ts
Normal file
12
apps/api/src/reader/reader.module.ts
Normal 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 {}
|
||||
@ -1,18 +1,56 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import AdmZip from "adm-zip";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { listCbzImageEntries } from "../common/cbz.js";
|
||||
import { extractMetadata } from "./metadata.js";
|
||||
|
||||
vi.mock("../common/cbr.js", () => ({
|
||||
listCbrImageEntries: async () => [{ entryName: "001.jpg", name: "001.jpg" }],
|
||||
readCbrPage: async () => ({ entryName: "001.jpg", data: Buffer.from([0xff, 0xd8, 0xff, 0xd9]) })
|
||||
}));
|
||||
|
||||
describe("pdf metadata extraction", () => {
|
||||
it("falls back to file name and reads simple PDF info fields", () => {
|
||||
it("falls back to file name and reads simple PDF info fields", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
|
||||
const file = join(dir, "Example.pdf");
|
||||
writeFileSync(file, "%PDF-1.4\n1 0 obj << /Title (My Book) /Author (Ada) >> endobj");
|
||||
|
||||
const metadata = extractMetadata(file, dir);
|
||||
const metadata = await extractMetadata(file, dir);
|
||||
|
||||
expect(metadata.title).toBe("My Book");
|
||||
expect(metadata.author).toBe("Ada");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cbz metadata extraction", () => {
|
||||
it("uses the file name as title and first image as cover", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
|
||||
const file = join(dir, "Comic One.cbz");
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("002.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
||||
zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
||||
zip.writeZip(file);
|
||||
|
||||
const metadata = await extractMetadata(file, dir);
|
||||
const pages = listCbzImageEntries(file);
|
||||
|
||||
expect(metadata.title).toBe("Comic One");
|
||||
expect(metadata.coverPath).toMatch(/covers\/[a-f0-9]+\.jpg$/);
|
||||
expect(pages.map((page) => page.name)).toEqual(["001.jpg", "002.jpg"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cbr metadata extraction", () => {
|
||||
it("uses the file name as title and first extracted image as cover", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
|
||||
const file = join(dir, "Comic Two.cbr");
|
||||
writeFileSync(file, "rar");
|
||||
|
||||
const metadata = await extractMetadata(file, dir);
|
||||
|
||||
expect(metadata.title).toBe("Comic Two");
|
||||
expect(metadata.coverPath).toMatch(/covers\/[a-f0-9]+\.jpg$/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,6 +3,9 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
|
||||
import { listCbzImageEntries } from "../common/cbz.js";
|
||||
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
|
||||
|
||||
export type BookMetadata = {
|
||||
title: string;
|
||||
@ -21,11 +24,17 @@ const xmlParser = new XMLParser({
|
||||
textNodeName: "#text"
|
||||
});
|
||||
|
||||
export function extractMetadata(filePath: string, storageDir: string): BookMetadata {
|
||||
export async function extractMetadata(filePath: string, storageDir: string): Promise<BookMetadata> {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
if (extension === ".epub") {
|
||||
return extractEpubMetadata(filePath, storageDir);
|
||||
}
|
||||
if (extension === ".cbz") {
|
||||
return extractCbzMetadata(filePath, storageDir);
|
||||
}
|
||||
if (extension === ".cbr") {
|
||||
return extractCbrMetadata(filePath, storageDir);
|
||||
}
|
||||
return extractPdfMetadata(filePath);
|
||||
}
|
||||
|
||||
@ -56,7 +65,7 @@ function extractEpubMetadata(filePath: string, storageDir: string): BookMetadata
|
||||
isbn,
|
||||
language: firstText(metadata["dc:language"]),
|
||||
publisher: firstText(metadata["dc:publisher"]),
|
||||
publishedDate: firstText(metadata["dc:date"]),
|
||||
publishedDate: normalizePublishedDate(firstText(metadata["dc:date"])),
|
||||
coverPath
|
||||
};
|
||||
}
|
||||
@ -78,6 +87,26 @@ function extractPdfMetadata(filePath: string): BookMetadata {
|
||||
};
|
||||
}
|
||||
|
||||
function extractCbzMetadata(filePath: string, storageDir: string): BookMetadata {
|
||||
const zip = new AdmZip(filePath);
|
||||
const firstPage = listCbzImageEntries(filePath)[0];
|
||||
const coverPath = extractCover(zip, firstPage.entryName, filePath, storageDir);
|
||||
return {
|
||||
...fallbackMetadata(filePath),
|
||||
coverPath
|
||||
};
|
||||
}
|
||||
|
||||
async function extractCbrMetadata(filePath: string, storageDir: string): Promise<BookMetadata> {
|
||||
const firstPage = (await listCbrImageEntries(filePath))[0];
|
||||
const page = await readCbrPage(filePath, 1, storageDir);
|
||||
const coverPath = writeCoverData(page.data, firstPage.entryName, filePath, storageDir);
|
||||
return {
|
||||
...fallbackMetadata(filePath),
|
||||
coverPath
|
||||
};
|
||||
}
|
||||
|
||||
function fallbackMetadata(filePath: string): BookMetadata {
|
||||
return {
|
||||
title: basename(filePath, extname(filePath)),
|
||||
@ -139,6 +168,15 @@ function extractCover(zip: AdmZip, coverPathInZip: string, filePath: string, sto
|
||||
return target;
|
||||
}
|
||||
|
||||
function writeCoverData(data: Buffer, entryName: string, filePath: string, storageDir: string): string {
|
||||
const extension = extname(entryName) || ".jpg";
|
||||
const hash = createHash("sha256").update(filePath).digest("hex").slice(0, 24);
|
||||
const target = join(storageDir, "covers", `${hash}${extension}`);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, data);
|
||||
return target;
|
||||
}
|
||||
|
||||
function matchPdfInfo(text: string, key: string): string | null {
|
||||
return text.match(new RegExp(`/${key}\\s*\\(([^)]{1,500})\\)`))?.[1] ?? null;
|
||||
}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { DatabaseModule } from "../database/database.module.js";
|
||||
import { JobsModule } from "../jobs/jobs.module.js";
|
||||
import { OpenLibraryService } from "./open-library.service.js";
|
||||
import { MetadataModule } from "../metadata/metadata.module.js";
|
||||
import { ScannerService } from "./scanner.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, JobsModule],
|
||||
providers: [ScannerService, OpenLibraryService],
|
||||
imports: [DatabaseModule, JobsModule, MetadataModule],
|
||||
providers: [ScannerService],
|
||||
exports: [ScannerService]
|
||||
})
|
||||
export class ScannerModule {}
|
||||
|
||||
252
apps/api/src/scanner/scanner.service.test.ts
Normal file
252
apps/api/src/scanner/scanner.service.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,19 +1,21 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { books, libraries } from "../database/schema.js";
|
||||
import { automationSettings, books, libraries, series } from "../database/schema.js";
|
||||
import { JobsService } from "../jobs/jobs.service.js";
|
||||
import { MetadataService } from "../metadata/metadata.service.js";
|
||||
import { extractSeriesVolume } from "../metadata/use-cases/extract-series-volume.js";
|
||||
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
|
||||
import { extractMetadata } from "./metadata.js";
|
||||
import { OpenLibraryService } from "./open-library.service.js";
|
||||
|
||||
@Injectable()
|
||||
export class ScannerService {
|
||||
constructor(
|
||||
private readonly database: DatabaseService,
|
||||
private readonly jobs: JobsService,
|
||||
private readonly openLibrary: OpenLibraryService
|
||||
private readonly metadata: MetadataService
|
||||
) {}
|
||||
|
||||
enqueueLibraryScan(libraryId: number) {
|
||||
@ -28,51 +30,271 @@ export class ScannerService {
|
||||
return job;
|
||||
}
|
||||
|
||||
enqueueAllLibrariesScan(detail = "Scanning all enabled libraries") {
|
||||
const job = this.jobs.create("library-scan-all", detail);
|
||||
setImmediate(() => {
|
||||
void this.scanAllLibraries(job.id).catch((error) => this.jobs.markFailed(job.id, error));
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
enqueueMetadataEnrichment(detail = "Enriching existing books") {
|
||||
const job = this.jobs.create("metadata-enrich", detail);
|
||||
setImmediate(() => {
|
||||
void this.enrichExistingBooks(job.id).catch((error) => this.jobs.markFailed(job.id, error));
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
private async scanLibrary(jobId: number, library: typeof libraries.$inferSelect): Promise<void> {
|
||||
this.jobs.markRunning(jobId, `Scanning ${library.path}`);
|
||||
let count = 0;
|
||||
const failures: ScanFailure[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const filePath of walkBooks(library.path)) {
|
||||
await this.ingestFile(library.id, filePath);
|
||||
count += 1;
|
||||
seen.add(filePath);
|
||||
try {
|
||||
await this.ingestFile(library.id, filePath);
|
||||
count += 1;
|
||||
} catch (error) {
|
||||
failures.push({ filePath, error: errorMessage(error) });
|
||||
this.ingestIncompleteFile(library.id, filePath);
|
||||
}
|
||||
}
|
||||
this.jobs.markSucceeded(jobId, `Scanned ${count} file(s)`);
|
||||
const removed = this.removeMissingBooks(library.id, seen);
|
||||
this.jobs.markSucceeded(jobId, scanDigest(count, removed, failures));
|
||||
}
|
||||
|
||||
private async scanAllLibraries(jobId: number): Promise<void> {
|
||||
this.jobs.markRunning(jobId, "Scanning all enabled libraries");
|
||||
const enabledLibraries = this.database.db.select().from(libraries).where(eq(libraries.enabled, true)).all();
|
||||
let scanned = 0;
|
||||
const failures: ScanFailure[] = [];
|
||||
for (const library of enabledLibraries) {
|
||||
for (const filePath of walkBooks(library.path)) {
|
||||
try {
|
||||
await this.ingestFile(library.id, filePath);
|
||||
scanned += 1;
|
||||
} catch (error) {
|
||||
failures.push({ filePath, error: errorMessage(error) });
|
||||
this.ingestIncompleteFile(library.id, filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.jobs.markSucceeded(jobId, scanDigest(scanned, 0, failures, `across ${enabledLibraries.length} library/libraries`));
|
||||
}
|
||||
|
||||
private async enrichExistingBooks(jobId: number): Promise<void> {
|
||||
this.jobs.markRunning(jobId, "Enriching existing books");
|
||||
const rows = this.database.db.select({ id: books.id }).from(books).all();
|
||||
let count = 0;
|
||||
const failures: ScanFailure[] = [];
|
||||
for (const row of rows) {
|
||||
this.markBookEnrichmentStatus(row.id, "running");
|
||||
try {
|
||||
await this.metadata.enrichBook(row.id);
|
||||
this.markBookEnrichmentStatus(row.id, "succeeded");
|
||||
count += 1;
|
||||
} catch (error) {
|
||||
this.markBookEnrichmentStatus(row.id, "failed");
|
||||
failures.push({ filePath: `book #${row.id}`, error: errorMessage(error) });
|
||||
}
|
||||
}
|
||||
this.jobs.markSucceeded(jobId, enrichmentDigest(count, failures));
|
||||
}
|
||||
|
||||
private async ingestFile(libraryId: number, filePath: string): Promise<void> {
|
||||
const stats = statSync(filePath);
|
||||
let metadata = extractMetadata(filePath, this.database.config.storageDir);
|
||||
if (this.database.config.openLibraryEnabled) {
|
||||
try {
|
||||
metadata = { ...metadata, ...(await this.openLibrary.enrich(metadata)) };
|
||||
} catch {
|
||||
// Remote enrichment is opportunistic; local ingestion must stay deterministic.
|
||||
}
|
||||
const existing = this.database.db.select().from(books).where(eq(books.filePath, filePath)).get();
|
||||
if (existing) {
|
||||
this.database.db
|
||||
.update(books)
|
||||
.set({ scanStatus: "running", enrichmentStatus: "running", updatedAt: this.database.now() })
|
||||
.where(eq(books.id, existing.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
const localMetadata = await extractMetadata(filePath, this.database.config.storageDir);
|
||||
const now = this.database.now();
|
||||
const format: "epub" | "pdf" = extname(filePath).toLowerCase() === ".epub" ? "epub" : "pdf";
|
||||
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get();
|
||||
const format = bookFormatFromPath(filePath);
|
||||
const shouldRemoteEnrich = !existing ? this.shouldAutoEnrichNewBooks() : true;
|
||||
const metadata = await this.metadata.enrichMetadata(localMetadata, filePath, { remote: shouldRemoteEnrich });
|
||||
const seriesInfo = this.resolveSeries(metadata.title, filePath);
|
||||
const values = {
|
||||
libraryId,
|
||||
seriesId: seriesInfo.seriesId,
|
||||
title: metadata.title,
|
||||
author: metadata.author,
|
||||
description: metadata.description,
|
||||
isbn: metadata.isbn,
|
||||
isbn13: metadata.isbn13,
|
||||
identifiersJson: metadata.identifiersJson,
|
||||
localMetadataJson: metadata.localMetadataJson,
|
||||
language: metadata.language,
|
||||
publisher: metadata.publisher,
|
||||
publishedDate: metadata.publishedDate,
|
||||
volumeNumber: seriesInfo.volumeNumber,
|
||||
volumeLabel: seriesInfo.volumeLabel,
|
||||
format,
|
||||
filePath,
|
||||
coverPath: metadata.coverPath,
|
||||
metadataStatus: metadata.metadataStatus,
|
||||
metadataProvenanceJson: metadata.metadataProvenanceJson,
|
||||
scanStatus: "succeeded" as const,
|
||||
enrichmentStatus: shouldRemoteEnrich ? ("succeeded" as const) : ("idle" as const),
|
||||
fileSize: stats.size,
|
||||
fileMtime: stats.mtime.toISOString(),
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
existing
|
||||
? this.database.db.update(books).set(values).where(eq(books.id, existing.id)).returning().get()
|
||||
: this.database.db.insert(books).values({ ...values, createdAt: now }).returning().get();
|
||||
this.upsertBookByFilePath(values, existing, now);
|
||||
}
|
||||
|
||||
private ingestIncompleteFile(libraryId: number, filePath: string): void {
|
||||
const stats = statSync(filePath);
|
||||
const now = this.database.now();
|
||||
const existing = this.database.db.select().from(books).where(eq(books.filePath, filePath)).get();
|
||||
const seriesInfo = this.resolveSeries(basename(filePath, extname(filePath)), filePath);
|
||||
const values = {
|
||||
libraryId,
|
||||
seriesId: seriesInfo.seriesId,
|
||||
title: basename(filePath, extname(filePath)),
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
identifiersJson: JSON.stringify({ candidates: [], isbn10: null, isbn13: null }),
|
||||
localMetadataJson: JSON.stringify({
|
||||
title: basename(filePath, extname(filePath)),
|
||||
author: null,
|
||||
year: null,
|
||||
isbn: null,
|
||||
fileTitle: basename(filePath, extname(filePath)),
|
||||
raw: {
|
||||
title: basename(filePath, extname(filePath)),
|
||||
author: null,
|
||||
publishedDate: null,
|
||||
fileName: basename(filePath, extname(filePath))
|
||||
}
|
||||
}),
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
volumeNumber: seriesInfo.volumeNumber,
|
||||
volumeLabel: seriesInfo.volumeLabel,
|
||||
format: bookFormatFromPath(filePath),
|
||||
filePath,
|
||||
coverPath: null,
|
||||
metadataStatus: "none" as const,
|
||||
metadataProvenanceJson: JSON.stringify({ title: "local" }),
|
||||
scanStatus: "failed" as const,
|
||||
enrichmentStatus: "failed" as const,
|
||||
fileSize: stats.size,
|
||||
fileMtime: stats.mtime.toISOString(),
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
this.upsertBookByFilePath(values, existing, now);
|
||||
}
|
||||
|
||||
private upsertBookByFilePath(
|
||||
values: Omit<typeof books.$inferInsert, "createdAt">,
|
||||
existing: typeof books.$inferSelect | undefined,
|
||||
createdAt: string
|
||||
): void {
|
||||
if (existing) {
|
||||
this.database.db.update(books).set(preserveExistingBookValues(values, existing)).where(eq(books.id, existing.id)).run();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.database.db.insert(books).values({ ...values, createdAt }).run();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isUniqueFilePathError(error)) throw error;
|
||||
const current = this.database.db.select().from(books).where(eq(books.filePath, values.filePath)).get();
|
||||
if (!current) throw error;
|
||||
this.database.db.update(books).set(preserveExistingBookValues(values, current)).where(eq(books.id, current.id)).run();
|
||||
}
|
||||
}
|
||||
|
||||
private removeMissingBooks(libraryId: number, seen: Set<string>): number {
|
||||
const existing = this.database.db.select({ id: books.id, filePath: books.filePath }).from(books).where(eq(books.libraryId, libraryId)).all();
|
||||
const missing = existing.filter((book) => !seen.has(book.filePath) && !existsSync(book.filePath));
|
||||
if (!missing.length) return 0;
|
||||
this.database.db.delete(books).where(inArray(books.id, missing.map((book) => book.id))).run();
|
||||
return missing.length;
|
||||
}
|
||||
|
||||
private shouldAutoEnrichNewBooks(): boolean {
|
||||
return Boolean(
|
||||
this.database.db
|
||||
.select({ autoEnrichNewBooks: automationSettings.autoEnrichNewBooks })
|
||||
.from(automationSettings)
|
||||
.where(eq(automationSettings.id, 1))
|
||||
.get()?.autoEnrichNewBooks
|
||||
);
|
||||
}
|
||||
|
||||
private markBookEnrichmentStatus(id: number, enrichmentStatus: "running" | "succeeded" | "failed"): void {
|
||||
this.database.db.update(books).set({ enrichmentStatus, updatedAt: this.database.now() }).where(eq(books.id, id)).run();
|
||||
}
|
||||
|
||||
private resolveSeries(title: string, filePath: string): { seriesId: number; volumeNumber: number | null; volumeLabel: string | null } {
|
||||
const parsed = extractSeriesVolume(title, filePath);
|
||||
const now = this.database.now();
|
||||
const row = this.database.db
|
||||
.insert(series)
|
||||
.values({
|
||||
title: parsed.seriesTitle,
|
||||
normalizedTitle: parsed.normalizedSeriesTitle,
|
||||
description: null,
|
||||
publisher: null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: series.normalizedTitle,
|
||||
set: { title: parsed.seriesTitle, updatedAt: now }
|
||||
})
|
||||
.returning({ id: series.id })
|
||||
.get();
|
||||
return { seriesId: row.id, volumeNumber: parsed.volumeNumber, volumeLabel: parsed.volumeLabel };
|
||||
}
|
||||
}
|
||||
|
||||
type ScanFailure = {
|
||||
filePath: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export function scanDigest(scanned: number, removed: number, failures: ScanFailure[], suffix?: string): string {
|
||||
const base = suffix ? `Scanned ${scanned} file(s) ${suffix}` : `Scanned ${scanned} file(s), removed ${removed} missing book(s)`;
|
||||
if (!failures.length) return base;
|
||||
const examples = failures
|
||||
.slice(0, 3)
|
||||
.map((failure) => `${basename(failure.filePath)}: ${truncate(failure.error)}`)
|
||||
.join("; ");
|
||||
const extra = failures.length > 3 ? `; ${failures.length - 3} more` : "";
|
||||
return `${base}, ${failures.length} incomplete file(s): ${examples}${extra}`;
|
||||
}
|
||||
|
||||
export function enrichmentDigest(enriched: number, failures: ScanFailure[]): string {
|
||||
const base = `Enriched ${enriched} book(s)`;
|
||||
if (!failures.length) return base;
|
||||
const examples = failures
|
||||
.slice(0, 3)
|
||||
.map((failure) => `${basename(failure.filePath)}: ${truncate(failure.error)}`)
|
||||
.join("; ");
|
||||
const extra = failures.length > 3 ? `; ${failures.length - 3} more` : "";
|
||||
return `${base}, ${failures.length} incomplete book(s): ${examples}${extra}`;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return truncate(error.message);
|
||||
return truncate(String(error));
|
||||
}
|
||||
|
||||
function truncate(value: string): string {
|
||||
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
|
||||
}
|
||||
|
||||
function* walkBooks(root: string): Generator<string> {
|
||||
@ -84,8 +306,62 @@ function* walkBooks(root: string): Generator<string> {
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const extension = extname(entry.name).toLowerCase();
|
||||
if (extension === ".epub" || extension === ".pdf") {
|
||||
if (extension === ".epub" || extension === ".pdf" || extension === ".cbz" || extension === ".cbr") {
|
||||
yield path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" | "cbr" {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
if (extension === ".epub") return "epub";
|
||||
if (extension === ".cbz") return "cbz";
|
||||
if (extension === ".cbr") return "cbr";
|
||||
return "pdf";
|
||||
}
|
||||
|
||||
export function preserveExistingBookValues<T extends Partial<typeof books.$inferInsert>>(values: T, existing: typeof books.$inferSelect): T {
|
||||
const next = { ...values };
|
||||
if (next.scanStatus === "failed" && existing.title) {
|
||||
next.title = existing.title as never;
|
||||
}
|
||||
for (const field of ["author", "description", "isbn", "isbn13", "language", "publisher", "publishedDate", "coverPath", "seriesId", "volumeNumber", "volumeLabel"] as const) {
|
||||
if (field === "publishedDate") {
|
||||
next.publishedDate = (normalizePublishedDate(next.publishedDate) ?? normalizePublishedDate(existing.publishedDate)) as never;
|
||||
continue;
|
||||
}
|
||||
if (next[field] == null && existing[field] != null) {
|
||||
next[field] = existing[field] as never;
|
||||
}
|
||||
}
|
||||
next.metadataStatus = computeMetadataStatus(next, existing.metadataStatus) as never;
|
||||
next.metadataProvenanceJson = mergeProvenanceJson(String(next.metadataProvenanceJson ?? "{}"), existing.metadataProvenanceJson) as never;
|
||||
return next;
|
||||
}
|
||||
|
||||
function computeMetadataStatus(values: Partial<typeof books.$inferInsert>, existingStatus: string): "enriched" | "partial" | "none" {
|
||||
const hasCover = Boolean(values.coverPath);
|
||||
const filled = [values.author, values.description, values.isbn, values.language, values.publisher, values.publishedDate].filter(Boolean).length;
|
||||
const computed = hasCover && filled >= 2 ? "enriched" : hasCover || filled > 0 ? "partial" : "none";
|
||||
const rank = { none: 0, partial: 1, enriched: 2 } as const;
|
||||
const safeExisting = existingStatus === "enriched" || existingStatus === "partial" || existingStatus === "none" ? existingStatus : "none";
|
||||
return rank[computed] >= rank[safeExisting] ? computed : safeExisting;
|
||||
}
|
||||
|
||||
function mergeProvenanceJson(nextJson: string, existingJson: string | null): string {
|
||||
return JSON.stringify({ ...parseJsonObject(existingJson), ...parseJsonObject(nextJson) });
|
||||
}
|
||||
|
||||
function parseJsonObject(value: string | null): Record<string, unknown> {
|
||||
if (!value) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueFilePathError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes("UNIQUE constraint failed: books.file_path");
|
||||
}
|
||||
|
||||
@ -4,26 +4,50 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /auth/ {
|
||||
proxy_pass http://api:3000/auth/;
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
try_files /sw.js =404;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ^~ /auth {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
proxy_pass http://api:3000/admin/;
|
||||
location ^~ /admin {
|
||||
if ($http_accept ~* "text/html") {
|
||||
rewrite ^ /index.html last;
|
||||
}
|
||||
proxy_pass http://api:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /books/ {
|
||||
proxy_pass http://api:3000/books/;
|
||||
location ^~ /books {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /progress/ {
|
||||
proxy_pass http://api:3000/progress/;
|
||||
location ^~ /series {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location ^~ /progress {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
"vite": "^8.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@napi-rs/canvas": "1.0.7",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
const CACHE_NAME = "readabook-shell-v1";
|
||||
const SHELL = ["/", "/home", "/manifest.webmanifest", "/icons/readabook.svg"];
|
||||
const CACHE_NAME = "readabook-shell-v2";
|
||||
const SHELL = ["/", "/index.html", "/manifest.webmanifest", "/icons/readabook.svg"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
|
||||
@ -18,5 +18,19 @@ self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET" || ["/auth", "/admin", "/books", "/progress"].some((path) => url.pathname.startsWith(path))) {
|
||||
return;
|
||||
}
|
||||
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((hit) => hit || caches.match("/"))));
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put("/index.html", copy));
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match("/index.html").then((hit) => hit || caches.match("/")))
|
||||
);
|
||||
return;
|
||||
}
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((hit) => hit || fetch(event.request))
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Session } from "./api/types";
|
||||
import { api } from "./api/client";
|
||||
import { isPrivateRoute } from "./auth/routing";
|
||||
import { AppShell } from "./layout/AppShell";
|
||||
import { AdminAutomationPage } from "./pages/AdminAutomationPage";
|
||||
import { AdminPage } from "./pages/AdminPage";
|
||||
import { BookPage } from "./pages/BookPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
@ -10,8 +12,9 @@ import { LoginPage } from "./pages/LoginPage";
|
||||
import { ProfilePage } from "./pages/ProfilePage";
|
||||
import { ReaderPage } from "./pages/ReaderPage";
|
||||
import { SearchPage } from "./pages/SearchPage";
|
||||
import { SeriesPage } from "./pages/SeriesPage";
|
||||
import { SetupPage } from "./pages/SetupPage";
|
||||
import { parseRoute, type Route } from "./router";
|
||||
import { navigate, parseRoute, type Route } from "./router";
|
||||
|
||||
function renderRoute(route: Route, session: Session, refreshSession: () => Promise<void>) {
|
||||
if (route.name === "login") return <LoginPage onSessionChange={refreshSession} />;
|
||||
@ -22,6 +25,8 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
|
||||
<HomePage />
|
||||
) : route.name === "library" ? (
|
||||
<LibraryPage libraryId={route.libraryId} />
|
||||
) : route.name === "catalogSeries" ? (
|
||||
<SeriesPage seriesName={route.seriesName} />
|
||||
) : route.name === "book" ? (
|
||||
<BookPage bookId={route.bookId} />
|
||||
) : route.name === "reader" ? (
|
||||
@ -30,19 +35,27 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
|
||||
<SearchPage />
|
||||
) : route.name === "me" ? (
|
||||
<ProfilePage session={session} onSessionChange={refreshSession} />
|
||||
) : route.name === "admin" && route.section === "automation" ? (
|
||||
<AdminAutomationPage />
|
||||
) : (
|
||||
<AdminPage />
|
||||
);
|
||||
|
||||
return <AppShell session={session}>{content}</AppShell>;
|
||||
return (
|
||||
<AppShell session={session} readerLayout={route.name === "reader"}>
|
||||
{content}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [route, setRoute] = useState(parseRoute());
|
||||
const [session, setSession] = useState<Session>({ user: null, degraded: false });
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
|
||||
async function refreshSession() {
|
||||
setSession(await api.session());
|
||||
setSessionChecked(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@ -55,5 +68,35 @@ export function App() {
|
||||
return () => window.removeEventListener("popstate", listener);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
setSession({ user: null, degraded: false });
|
||||
setSessionChecked(true);
|
||||
if (isPrivateRoute(parseRoute())) navigate("/login");
|
||||
};
|
||||
window.addEventListener("readabook:session-expired", listener);
|
||||
return () => window.removeEventListener("readabook:session-expired", listener);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionChecked && !session.user && isPrivateRoute(route)) navigate("/login");
|
||||
}, [route, session.user, sessionChecked]);
|
||||
|
||||
if (!sessionChecked && isPrivateRoute(route)) {
|
||||
return (
|
||||
<div className="auth-surface">
|
||||
<section className="auth-hero">
|
||||
<p>Cabinet de curiosites numerique</p>
|
||||
<h1>ReadaBook</h1>
|
||||
<span>Verification de session.</span>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionChecked && !session.user && isPrivateRoute(route)) {
|
||||
return <LoginPage onSessionChange={refreshSession} />;
|
||||
}
|
||||
|
||||
return renderRoute(route, session, refreshSession);
|
||||
}
|
||||
|
||||
151
apps/web/src/api/client.test.ts
Normal file
151
apps/web/src/api/client.test.ts
Normal 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 }
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,19 +1,35 @@
|
||||
import type {
|
||||
BookDto,
|
||||
BookQueryDto,
|
||||
AuthStatusDto,
|
||||
AutomationSettingsDto,
|
||||
BootstrapAdminDto,
|
||||
CreateLibraryDto,
|
||||
JobDto,
|
||||
LibraryDto,
|
||||
LoginDto,
|
||||
MetadataSourcesConfigDto,
|
||||
ProgressDto,
|
||||
UpdateAutomationSettingsDto,
|
||||
UpdateAccountDto,
|
||||
UpdateMetadataSourcesConfigDto,
|
||||
UpdateProgressDto,
|
||||
UserDto
|
||||
} from "@readabook/shared";
|
||||
import { mockBooks, mockContinue, mockJobs, mockLibraries, mockProgress, mockUser } from "./mockData";
|
||||
import type { ContinueItem, Session } from "./types";
|
||||
import {
|
||||
mockAutomationSettings,
|
||||
mockBooks,
|
||||
mockContinue,
|
||||
mockJobs,
|
||||
mockLibraries,
|
||||
mockMetadataSources,
|
||||
mockProgress,
|
||||
mockUser
|
||||
} from "./mockData";
|
||||
import type { CbzPagesDto, ContinueItem, ReaderPreferencesDto, Session } from "./types";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:";
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
fallback?: unknown;
|
||||
@ -28,20 +44,54 @@ export class ApiFallbackError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiHttpError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function getApiFallback<T>(error: unknown): T | undefined {
|
||||
return error instanceof ApiFallbackError ? (error.fallback as T) : undefined;
|
||||
}
|
||||
|
||||
function apiErrorMessage(detail: string, fallback: string): string {
|
||||
if (!detail) return fallback;
|
||||
try {
|
||||
const parsed = JSON.parse(detail) as { message?: unknown; error?: unknown };
|
||||
if (typeof parsed.message === "string") return parsed.message;
|
||||
if (Array.isArray(parsed.message)) return parsed.message.join(", ");
|
||||
if (typeof parsed.error === "string") return parsed.error;
|
||||
} catch {
|
||||
return detail;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function requestHeaders(options: RequestOptions): Headers {
|
||||
const headers = new Headers(options.headers);
|
||||
if (options.body !== undefined && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers
|
||||
}
|
||||
headers: requestHeaders(options)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("readabook:session-expired"));
|
||||
}
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || `${response.status} ${response.statusText}`);
|
||||
throw new ApiHttpError(response.status, apiErrorMessage(detail, `${response.status} ${response.statusText}`));
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
@ -62,6 +112,30 @@ function queryString(query: Partial<BookQueryDto>): string {
|
||||
return value ? `?${value}` : "";
|
||||
}
|
||||
|
||||
function readerPreferencesKey(bookId: number): string {
|
||||
return `${READER_PREFERENCES_PREFIX}${bookId}`;
|
||||
}
|
||||
|
||||
function readLocalReaderPreferences(bookId: number): ReaderPreferencesDto {
|
||||
if (typeof localStorage === "undefined") return { mode: "horizontal", fit: "page" };
|
||||
const raw = localStorage.getItem(readerPreferencesKey(bookId));
|
||||
if (!raw) return { mode: "horizontal", fit: "page" };
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<ReaderPreferencesDto>;
|
||||
return {
|
||||
mode: parsed.mode === "vertical" ? "vertical" : "horizontal",
|
||||
fit: parsed.fit === "width" ? "width" : "page"
|
||||
};
|
||||
} catch {
|
||||
return { mode: "horizontal", fit: "page" };
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalReaderPreferences(bookId: number, preferences: ReaderPreferencesDto): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(readerPreferencesKey(bookId), JSON.stringify(preferences));
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async session(): Promise<Session> {
|
||||
try {
|
||||
@ -71,6 +145,9 @@ export const api = {
|
||||
return { user: null, degraded: false };
|
||||
}
|
||||
},
|
||||
async authStatus(): Promise<AuthStatusDto> {
|
||||
return request<AuthStatusDto>("/auth/status");
|
||||
},
|
||||
async bootstrap(input: BootstrapAdminDto): Promise<UserDto> {
|
||||
const result = await request<UserDto>("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) });
|
||||
return result;
|
||||
@ -82,11 +159,14 @@ export const api = {
|
||||
async logout(): Promise<void> {
|
||||
await request<{ ok: true }>("/auth/logout", { method: "POST" });
|
||||
},
|
||||
async updateMe(input: UpdateAccountDto): Promise<UserDto> {
|
||||
return request<UserDto>("/auth/me", { method: "PATCH", body: JSON.stringify(input) });
|
||||
},
|
||||
async books(query: Partial<BookQueryDto> = {}): Promise<BookDto[]> {
|
||||
return request<BookDto[]>(`/books${queryString({ limit: 50, offset: 0, ...query })}`, { fallback: mockBooks });
|
||||
return request<BookDto[]>(`/books${queryString(query)}`, { fallback: mockBooks });
|
||||
},
|
||||
async search(query: string): Promise<BookDto[]> {
|
||||
return request<BookDto[]>(`/books/search${queryString({ q: query, limit: 50, offset: 0 })}`, { fallback: mockBooks });
|
||||
return request<BookDto[]>(`/books/search${queryString({ q: query })}`, { fallback: mockBooks });
|
||||
},
|
||||
async book(id: number): Promise<BookDto> {
|
||||
const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0];
|
||||
@ -98,6 +178,12 @@ export const api = {
|
||||
bookCoverUrl(id: number): string {
|
||||
return `${API_BASE}/books/${id}/cover`;
|
||||
},
|
||||
async cbzPages(id: number): Promise<CbzPagesDto> {
|
||||
return request<CbzPagesDto>(`/books/${id}/pages`);
|
||||
},
|
||||
cbzPageUrl(id: number, page: number): string {
|
||||
return `${API_BASE}/books/${id}/pages/${page}`;
|
||||
},
|
||||
async progress(bookId: number): Promise<ProgressDto | null> {
|
||||
try {
|
||||
return await request<ProgressDto>(`/progress/${bookId}`, {
|
||||
@ -115,6 +201,31 @@ export const api = {
|
||||
fallback: { bookId, ...input, updatedAt: new Date().toISOString() }
|
||||
});
|
||||
},
|
||||
async readerPreferences(bookId: number): Promise<ReaderPreferencesDto> {
|
||||
try {
|
||||
const preferences = await request<ReaderPreferencesDto>(`/reader/preferences/${bookId}`, {
|
||||
fallback: readLocalReaderPreferences(bookId)
|
||||
});
|
||||
writeLocalReaderPreferences(bookId, preferences);
|
||||
return preferences;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto;
|
||||
return readLocalReaderPreferences(bookId);
|
||||
}
|
||||
},
|
||||
async saveReaderPreferences(bookId: number, input: ReaderPreferencesDto): Promise<ReaderPreferencesDto> {
|
||||
writeLocalReaderPreferences(bookId, input);
|
||||
try {
|
||||
return await request<ReaderPreferencesDto>(`/reader/preferences/${bookId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
fallback: input
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto;
|
||||
return input;
|
||||
}
|
||||
},
|
||||
async continueReading(): Promise<ContinueItem[]> {
|
||||
return request<ContinueItem[]>("/progress/continue", { fallback: mockContinue });
|
||||
},
|
||||
@ -124,17 +235,45 @@ export const api = {
|
||||
async createLibrary(input: CreateLibraryDto): Promise<LibraryDto> {
|
||||
return request<LibraryDto>("/admin/libraries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
fallback: { id: Date.now(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...input }
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
},
|
||||
async deleteLibrary(id: number): Promise<void> {
|
||||
await request<{ ok: true }>(`/admin/libraries/${id}`, { method: "DELETE" });
|
||||
},
|
||||
async scanLibrary(id: number): Promise<JobDto> {
|
||||
return request<JobDto>(`/admin/libraries/${id}/scan`, { method: "POST", fallback: mockJobs[0] });
|
||||
return request<JobDto>(`/admin/libraries/${id}/scan`, { method: "POST" });
|
||||
},
|
||||
async jobs(): Promise<JobDto[]> {
|
||||
return request<JobDto[]>("/admin/jobs", { fallback: mockJobs });
|
||||
},
|
||||
async users(): Promise<UserDto[]> {
|
||||
return request<UserDto[]>("/admin/users", { fallback: [mockUser] });
|
||||
},
|
||||
async metadataSources(): Promise<MetadataSourcesConfigDto> {
|
||||
return request<MetadataSourcesConfigDto>("/admin/metadata-sources", { fallback: mockMetadataSources });
|
||||
},
|
||||
async updateMetadataSources(input: UpdateMetadataSourcesConfigDto): Promise<MetadataSourcesConfigDto> {
|
||||
return request<MetadataSourcesConfigDto>("/admin/metadata-sources", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
fallback: mockMetadataSources
|
||||
});
|
||||
},
|
||||
async automationSettings(): Promise<AutomationSettingsDto> {
|
||||
return request<AutomationSettingsDto>("/admin/automation", { fallback: mockAutomationSettings });
|
||||
},
|
||||
async updateAutomationSettings(input: UpdateAutomationSettingsDto): Promise<AutomationSettingsDto> {
|
||||
return request<AutomationSettingsDto>("/admin/automation", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
fallback: mockAutomationSettings
|
||||
});
|
||||
},
|
||||
async runAutomationScan(): Promise<JobDto> {
|
||||
return request<JobDto>("/admin/automation/run-scan", { method: "POST", fallback: mockJobs[0] });
|
||||
},
|
||||
async runAutomationEnrich(): Promise<JobDto> {
|
||||
return request<JobDto>("/admin/automation/run-enrich", { method: "POST", fallback: mockJobs[0] });
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,8 +1,27 @@
|
||||
import type { BookDto, JobDto, LibraryDto, ProgressDto, UserDto } from "@readabook/shared";
|
||||
import type { AutomationSettingsDto, BookDto, JobDto, LibraryDto, MetadataSourcesConfigDto, ProgressDto, UserDto } from "@readabook/shared";
|
||||
import type { ContinueItem } from "./types";
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
type BookPipelineStatus = "idle" | "running" | "succeeded" | "failed";
|
||||
type BookMetadataStatus = "enriched" | "partial" | "none";
|
||||
type MockBookDto = Omit<BookDto, "metadataStatus" | "metadataProvenance" | "scanStatus" | "enrichmentStatus"> & {
|
||||
metadataStatus?: BookMetadataStatus;
|
||||
metadataProvenance?: Record<string, string>;
|
||||
scanStatus?: BookPipelineStatus;
|
||||
enrichmentStatus?: BookPipelineStatus;
|
||||
};
|
||||
|
||||
function mockBook(book: MockBookDto): BookDto {
|
||||
return {
|
||||
metadataStatus: "partial",
|
||||
metadataProvenance: { local: "fixture" },
|
||||
scanStatus: "idle",
|
||||
enrichmentStatus: "idle",
|
||||
...book
|
||||
} as BookDto;
|
||||
}
|
||||
|
||||
export const mockUser: UserDto = {
|
||||
id: 1,
|
||||
email: "admin@readabook.local",
|
||||
@ -17,13 +36,14 @@ export const mockLibraries: LibraryDto[] = [
|
||||
];
|
||||
|
||||
export const mockBooks: BookDto[] = [
|
||||
{
|
||||
mockBook({
|
||||
id: 1,
|
||||
libraryId: 1,
|
||||
title: "L'Herbier des machines",
|
||||
author: "M. Valrose",
|
||||
description: "Fragments, croquis et notes rassemblees autour d'automates introuvables.",
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
language: "fr",
|
||||
publisher: "Cabinet ReadaBook",
|
||||
publishedDate: "1908",
|
||||
@ -34,14 +54,15 @@ export const mockBooks: BookDto[] = [
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
}),
|
||||
mockBook({
|
||||
id: 2,
|
||||
libraryId: 2,
|
||||
title: "Cartographie des songes",
|
||||
author: "I. Nadir",
|
||||
description: "Un atlas annote ou chaque page devient une vitrine de lecture.",
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
language: "fr",
|
||||
publisher: "ReadaBook",
|
||||
publishedDate: "1921",
|
||||
@ -52,12 +73,52 @@ export const mockBooks: BookDto[] = [
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
}),
|
||||
mockBook({
|
||||
id: 3,
|
||||
libraryId: 2,
|
||||
title: "Les vitrines de verre",
|
||||
author: "A. Muze",
|
||||
description: "Un recit graphique indexe comme archive CBZ.",
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
language: "fr",
|
||||
publisher: "ReadaBook",
|
||||
publishedDate: "1934",
|
||||
format: "cbz",
|
||||
filePath: "/library/cbz/vitrines.cbz",
|
||||
coverPath: null,
|
||||
fileSize: 12600000,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}),
|
||||
mockBook({
|
||||
id: 4,
|
||||
libraryId: 2,
|
||||
title: "Cabinet noir",
|
||||
author: "L. Rar",
|
||||
description: "Archive CBR lue avec le même parcours paginé que les comics CBZ.",
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
language: "fr",
|
||||
publisher: "ReadaBook",
|
||||
publishedDate: "1937",
|
||||
format: "cbr",
|
||||
filePath: "/library/cbr/cabinet-noir.cbr",
|
||||
coverPath: null,
|
||||
fileSize: 14800000,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
];
|
||||
|
||||
export const mockProgress: ProgressDto[] = [
|
||||
{ bookId: 1, locator: "mock:chapter-3", percent: 42, updatedAt: now },
|
||||
{ bookId: 2, locator: "mock:page-12", percent: 18, updatedAt: now }
|
||||
{ bookId: 2, locator: "pdf:page:12", percent: 18, updatedAt: now },
|
||||
{ bookId: 3, locator: "cbz:page:4", percent: 40, updatedAt: now },
|
||||
{ bookId: 4, locator: "cbr:page:6", percent: 60, updatedAt: now }
|
||||
];
|
||||
|
||||
export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({
|
||||
@ -68,3 +129,20 @@ export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({
|
||||
export const mockJobs: JobDto[] = [
|
||||
{ id: 1, type: "scan-library", status: "succeeded", detail: "2 ouvrages indexes", error: null, createdAt: now, updatedAt: now }
|
||||
];
|
||||
|
||||
export const mockMetadataSources: MetadataSourcesConfigDto = {
|
||||
isbnPriorityEnabled: true,
|
||||
sources: [
|
||||
{ provider: "local", enabled: true, priority: 0, hasApiKey: false },
|
||||
{ provider: "openlibrary", enabled: true, priority: 1, hasApiKey: false },
|
||||
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: false },
|
||||
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false }
|
||||
]
|
||||
};
|
||||
|
||||
export const mockAutomationSettings: AutomationSettingsDto = {
|
||||
watchLibraries: false,
|
||||
autoEnrichNewBooks: true,
|
||||
scanSchedule: { frequency: "disabled", time: "03:00", dayOfWeek: 1 },
|
||||
enrichSchedule: { frequency: "weekly", time: "04:00", dayOfWeek: 1 }
|
||||
};
|
||||
|
||||
@ -21,3 +21,32 @@ export type DashboardData = {
|
||||
libraries: LibraryDto[];
|
||||
jobs: JobDto[];
|
||||
};
|
||||
|
||||
export type CbzPagesDto = {
|
||||
bookId: number;
|
||||
pageCount: number;
|
||||
pages: Array<{ page: number; name: string }>;
|
||||
};
|
||||
|
||||
export type ReaderMode = "horizontal" | "vertical";
|
||||
|
||||
export type ReaderFit = "page" | "width";
|
||||
|
||||
export type ReaderPreferencesDto = {
|
||||
mode: ReaderMode;
|
||||
fit?: ReaderFit;
|
||||
};
|
||||
|
||||
export function hasActiveCoverWork(jobs: JobDto[]) {
|
||||
return jobs.some((job) => {
|
||||
if (job.status !== "queued" && job.status !== "running") return false;
|
||||
const type = job.type.toLowerCase();
|
||||
return type.includes("scan") || type.includes("enrich") || type.includes("metadata") || type.includes("cover");
|
||||
});
|
||||
}
|
||||
|
||||
export function isBookCoverUpdating(book: BookDto, fallbackActive = false) {
|
||||
const statuses = book as BookDto & { scanStatus?: string; enrichmentStatus?: string };
|
||||
if (statuses.scanStatus === "running" || statuses.enrichmentStatus === "running") return true;
|
||||
return statuses.scanStatus === undefined && statuses.enrichmentStatus === undefined && fallbackActive;
|
||||
}
|
||||
|
||||
15
apps/web/src/auth/errors.test.ts
Normal file
15
apps/web/src/auth/errors.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
11
apps/web/src/auth/errors.ts
Normal file
11
apps/web/src/auth/errors.ts
Normal 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.";
|
||||
}
|
||||
15
apps/web/src/auth/routing.test.ts
Normal file
15
apps/web/src/auth/routing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
9
apps/web/src/auth/routing.ts
Normal file
9
apps/web/src/auth/routing.ts
Normal 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);
|
||||
}
|
||||
14
apps/web/src/book/description.test.ts
Normal file
14
apps/web/src/book/description.test.ts
Normal 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 & second.</p><p><strong>Suite</strong> 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.");
|
||||
});
|
||||
});
|
||||
28
apps/web/src/book/description.ts
Normal file
28
apps/web/src/book/description.ts
Normal 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(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'");
|
||||
}
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.innerHTML = value;
|
||||
return textarea.value;
|
||||
}
|
||||
|
||||
export function cleanBookDescription(description?: string | null): string {
|
||||
if (!description) return "Notice absente du catalogue.";
|
||||
return decodeEntities(description.replace(blockBreakPattern, "\n").replace(tagPattern, " "))
|
||||
.replace(/\r/g, "")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n[ \t]+/g, "\n")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim() || "Notice absente du catalogue.";
|
||||
}
|
||||
131
apps/web/src/book/metadata.test.ts
Normal file
131
apps/web/src/book/metadata.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
171
apps/web/src/book/metadata.ts
Normal file
171
apps/web/src/book/metadata.ts
Normal 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";
|
||||
}
|
||||
@ -1,22 +1,35 @@
|
||||
import { BookOpen, Eye } from "lucide-react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { bookCardVolumeLabel, bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, displayPublishedDate } from "../book/metadata";
|
||||
import { navigate } from "../router";
|
||||
import { FormatPill } from "./ui";
|
||||
|
||||
export function BookCard({ book, compact = false }: { book: BookDto; compact?: boolean }) {
|
||||
export function BookCard({ book, compact = false, coverLoading = false }: { book: BookDto; compact?: boolean; coverLoading?: boolean }) {
|
||||
const metadataState = bookMetadataState(book);
|
||||
const publishedDate = displayPublishedDate(book.publishedDate);
|
||||
const volumeLabel = bookCardVolumeLabel(book);
|
||||
|
||||
return (
|
||||
<article className={`book-card ${compact ? "book-card-compact" : ""}`}>
|
||||
<button className="cover-button" onClick={() => navigate(`/book/${book.id}`)} aria-label={`Ouvrir ${book.title}`}>
|
||||
{book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={34} />}
|
||||
{coverLoading && <span className="cover-loading" aria-label="Jaquette en cours de mise à jour" />}
|
||||
</button>
|
||||
<div className="book-card-body">
|
||||
<div className="book-card-meta">
|
||||
<FormatPill format={book.format} />
|
||||
<span>{book.language ?? "langue inconnue"}</span>
|
||||
{volumeLabel && <span className="volume-pill">{volumeLabel}</span>}
|
||||
<span className="book-card-language">{book.language ?? "langue inconnue"}</span>
|
||||
</div>
|
||||
<h3>{book.title}</h3>
|
||||
<h3>{bookDisplayTitle(book)}</h3>
|
||||
<p>{book.author ?? "Auteur inconnu"}</p>
|
||||
{(volumeLabel || publishedDate) && (
|
||||
<p className="book-card-submeta">
|
||||
{[volumeLabel, publishedDate].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
|
||||
{!compact && <p className="book-card-description">{book.description ?? "Notice absente du catalogue."}</p>}
|
||||
<div className="book-card-actions">
|
||||
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}>
|
||||
|
||||
@ -28,7 +28,7 @@ export function ErrorRibbon({ message }: { message?: string }) {
|
||||
return <div className="error-ribbon">{message}</div>;
|
||||
}
|
||||
|
||||
export function FormatPill({ format }: { format: "epub" | "pdf" }) {
|
||||
export function FormatPill({ format }: { format: "epub" | "pdf" | "cbz" | "cbr" }) {
|
||||
return <span className={`format-pill format-${format}`}>{format.toUpperCase()}</span>;
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Archive, Home, Search, Settings, UserRound } from "lucide-react";
|
||||
import { Archive, Home, Search, Settings, SlidersHorizontal, UserRound } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Session } from "../api/types";
|
||||
import { navigate } from "../router";
|
||||
@ -7,10 +7,11 @@ const navItems = [
|
||||
{ href: "/home", label: "Accueil", icon: Home },
|
||||
{ href: "/search", label: "Recherche", icon: Search },
|
||||
{ href: "/admin/libraries", label: "Admin", icon: Settings },
|
||||
{ href: "/admin/automation", label: "Automatisation", icon: SlidersHorizontal },
|
||||
{ href: "/me", label: "Profil", icon: UserRound }
|
||||
];
|
||||
|
||||
export function AppShell({ children, session }: { children: ReactNode; session: Session }) {
|
||||
export function AppShell({ children, session, readerLayout = false }: { children: ReactNode; session: Session; readerLayout?: boolean }) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="side-rail">
|
||||
@ -31,7 +32,7 @@ export function AppShell({ children, session }: { children: ReactNode; session:
|
||||
</nav>
|
||||
<div className="session-chip">{session.user ? session.user.email : "Mode vitrine"}</div>
|
||||
</aside>
|
||||
<main>{children}</main>
|
||||
<main className={readerLayout ? "app-main-reader" : undefined}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
517
apps/web/src/pages/AdminAutomationPage.tsx
Normal file
517
apps/web/src/pages/AdminAutomationPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,67 +1,152 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Play, Plus } from "lucide-react";
|
||||
import { Play, Plus, Trash2 } from "lucide-react";
|
||||
import type { JobDto, LibraryDto, UserDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { ErrorRibbon, LoadingState, Panel } from "../components/ui";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { jobDigestSummary } from "../book/metadata";
|
||||
import { EmptyState, ErrorRibbon, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
function formatJobTime(value: string) {
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(value));
|
||||
}
|
||||
|
||||
export function AdminPage() {
|
||||
const [libraries, setLibraries] = useState<LibraryDto[] | null>(null);
|
||||
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
|
||||
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||||
const [users, setUsers] = useState<UserDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [name, setName] = useState("Bibliotheque locale");
|
||||
const [path, setPath] = useState("/library");
|
||||
const [error, setError] = useState<string>();
|
||||
const [success, setSuccess] = useState<string>();
|
||||
const [scanRetryLibrary, setScanRetryLibrary] = useState<LibraryDto>();
|
||||
|
||||
async function refresh() {
|
||||
const [nextLibraries, nextJobs, nextUsers] = await Promise.all([api.libraries(), api.jobs(), api.users()]);
|
||||
setLibraries(nextLibraries);
|
||||
setJobs(nextJobs);
|
||||
setUsers(nextUsers);
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
const [libraryResult, jobResult, userResult] = await Promise.allSettled([api.libraries(), api.jobs(), api.users()]);
|
||||
const errors: string[] = [];
|
||||
|
||||
if (libraryResult.status === "fulfilled") {
|
||||
setLibraries(libraryResult.value);
|
||||
} else {
|
||||
setLibraries(getApiFallback<LibraryDto[]>(libraryResult.reason) ?? []);
|
||||
errors.push("bibliotheques");
|
||||
}
|
||||
|
||||
if (jobResult.status === "fulfilled") {
|
||||
setJobs(jobResult.value);
|
||||
} else {
|
||||
setJobs(getApiFallback<JobDto[]>(jobResult.reason) ?? []);
|
||||
errors.push("travaux");
|
||||
}
|
||||
|
||||
if (userResult.status === "fulfilled") {
|
||||
setUsers(userResult.value);
|
||||
} else {
|
||||
setUsers(getApiFallback<UserDto[]>(userResult.reason) ?? []);
|
||||
errors.push("comptes");
|
||||
}
|
||||
|
||||
setError(errors.length ? `Donnees admin degradees : ${errors.join(", ")}.` : undefined);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch((refreshError) => setError(refreshError instanceof Error ? refreshError.message : "Administration indisponible"));
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
async function createLibrary(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(undefined);
|
||||
setSuccess(undefined);
|
||||
setScanRetryLibrary(undefined);
|
||||
try {
|
||||
await api.createLibrary({ name, path, enabled: true });
|
||||
const created = await api.createLibrary({ name, path, enabled: true });
|
||||
await refresh();
|
||||
setName("Bibliotheque locale");
|
||||
setPath("/library");
|
||||
try {
|
||||
await api.scanLibrary(created.id);
|
||||
await refresh();
|
||||
setSuccess(`Bibliothèque "${created.name}" ajoutée. Scan initial demandé.`);
|
||||
} catch (scanError) {
|
||||
setScanRetryLibrary(created);
|
||||
setSuccess(
|
||||
`Bibliothèque "${created.name}" ajoutée, mais le scan initial n'a pas pu être demandé. Tu peux réessayer le scan.`
|
||||
);
|
||||
setError(scanError instanceof Error ? `Scan initial impossible : ${scanError.message}` : "Scan initial impossible.");
|
||||
}
|
||||
} catch (createError) {
|
||||
setError(createError instanceof Error ? createError.message : "Creation impossible");
|
||||
setError(createError instanceof Error ? `Création impossible : ${createError.message}` : "Création impossible.");
|
||||
}
|
||||
}
|
||||
|
||||
async function scan(id: number) {
|
||||
setError(undefined);
|
||||
setSuccess(undefined);
|
||||
setScanRetryLibrary(undefined);
|
||||
try {
|
||||
await api.scanLibrary(id);
|
||||
await refresh();
|
||||
setSuccess("Scan demandé.");
|
||||
} catch (scanError) {
|
||||
setError(scanError instanceof Error ? scanError.message : "Scan impossible");
|
||||
setError(scanError instanceof Error ? `Scan impossible : ${scanError.message}` : "Scan impossible.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!libraries) return <LoadingState />;
|
||||
async function deleteLibrary(library: LibraryDto) {
|
||||
const confirmed = window.confirm(
|
||||
`Supprimer la bibliothèque "${library.name}" ?\n\nLes livres restent sur le disque. ReadaBook supprimera seulement cette bibliothèque du catalogue.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setError(undefined);
|
||||
setSuccess(undefined);
|
||||
setScanRetryLibrary(undefined);
|
||||
try {
|
||||
await api.deleteLibrary(library.id);
|
||||
setLibraries((current) => current.filter((item) => item.id !== library.id));
|
||||
setSuccess(`Bibliothèque "${library.name}" supprimée. Les fichiers disque n'ont pas été supprimés.`);
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "Suppression impossible");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Panel className="span-2">
|
||||
<div className="section-heading">
|
||||
<h1>Administration</h1>
|
||||
<span>{users.length} comptes</span>
|
||||
<span>{loading ? "chargement" : `${users.length} comptes`}</span>
|
||||
</div>
|
||||
<ErrorRibbon message={error} />
|
||||
{success && <div className="success-ribbon">{success}</div>}
|
||||
{scanRetryLibrary ? (
|
||||
<div className="retry-row">
|
||||
<span>La bibliothèque est conservée dans la liste.</span>
|
||||
<button className="ghost-button" onClick={() => scan(scanRetryLibrary.id)}>
|
||||
<Play size={16} />
|
||||
Réessayer le scan
|
||||
</button>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="retry-row">
|
||||
<span>Les formulaires restent disponibles.</span>
|
||||
<button className="ghost-button" onClick={() => void refresh()}>
|
||||
Reessayer
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<form className="admin-form" onSubmit={createLibrary}>
|
||||
<label>
|
||||
Nom du rayon
|
||||
Nom de la bibliothèque
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} required />
|
||||
<small>Nom affiché dans l’application, par exemple : Romans, BD, Documentation.</small>
|
||||
</label>
|
||||
<label>
|
||||
Chemin serveur
|
||||
Chemin du dossier
|
||||
<input value={path} onChange={(event) => setPath(event.target.value)} required />
|
||||
<small>Emplacement réel sur le serveur où ReadaBook lit les livres.</small>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
<Plus size={17} />
|
||||
@ -75,32 +160,54 @@ export function AdminPage() {
|
||||
<h2>Travaux</h2>
|
||||
<span>{jobs.length}</span>
|
||||
</div>
|
||||
<div className="job-list">
|
||||
{jobs.map((job) => (
|
||||
<div key={job.id}>
|
||||
<strong>{job.type}</strong>
|
||||
<span>{job.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{loading && !jobs.length ? (
|
||||
<LoadingState label="Lecture des travaux" />
|
||||
) : jobs.length ? (
|
||||
<div className="job-list">
|
||||
{jobs.map((job) => (
|
||||
<div key={job.id}>
|
||||
<div className="job-copy">
|
||||
<div>
|
||||
<strong>{job.type}</strong>
|
||||
<small>{jobDigestSummary(job)}</small>
|
||||
</div>
|
||||
<time dateTime={job.updatedAt}>{formatJobTime(job.updatedAt)}</time>
|
||||
</div>
|
||||
<span>{job.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Aucun travail" detail="Les scans apparaitront ici." />
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel className="span-3">
|
||||
<div className="library-table">
|
||||
{libraries.map((library) => (
|
||||
<div key={library.id}>
|
||||
<div>
|
||||
<strong>{library.name}</strong>
|
||||
<span>{library.path}</span>
|
||||
{loading && !libraries.length ? (
|
||||
<LoadingState label="Lecture des rayons" />
|
||||
) : libraries.length ? (
|
||||
<div className="library-table">
|
||||
{libraries.map((library) => (
|
||||
<div key={library.id}>
|
||||
<div className="library-copy">
|
||||
<strong>{library.name}</strong>
|
||||
<span>Chemin : {library.path}</span>
|
||||
</div>
|
||||
<span>{library.enabled ? "actif" : "pause"}</span>
|
||||
<button className="ghost-button" onClick={() => scan(library.id)}>
|
||||
<Play size={16} />
|
||||
Scanner
|
||||
</button>
|
||||
<button className="ghost-button danger-button" onClick={() => void deleteLibrary(library)}>
|
||||
<Trash2 size={16} />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
<span>{library.enabled ? "actif" : "pause"}</span>
|
||||
<button className="ghost-button" onClick={() => scan(library.id)}>
|
||||
<Play size={16} />
|
||||
Scanner
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Aucun rayon" detail="Ajoute un chemin puis relance la synchronisation." />
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
|
||||
23
apps/web/src/pages/BookErrorPages.test.ts
Normal file
23
apps/web/src/pages/BookErrorPages.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
@ -1,27 +1,79 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { BookOpen, LibraryBig } from "lucide-react";
|
||||
import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
|
||||
import type { BookDto, ProgressDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
||||
import { cleanBookDescription } from "../book/description";
|
||||
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata";
|
||||
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
|
||||
export function BookPage({ bookId }: { bookId: number }) {
|
||||
const [book, setBook] = useState<BookDto | null>(null);
|
||||
const [progress, setProgress] = useState<ProgressDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
async function loadBook() {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const nextBook = await api.book(bookId);
|
||||
setBook(nextBook);
|
||||
try {
|
||||
setProgress(await api.progress(bookId));
|
||||
} catch {
|
||||
setProgress(null);
|
||||
}
|
||||
} catch {
|
||||
setBook(null);
|
||||
setProgress(null);
|
||||
setError("Ce livre n'existe pas dans le catalogue.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
Promise.all([api.book(bookId), api.progress(bookId)]).then(([nextBook, nextProgress]) => {
|
||||
loadBook().finally(() => {
|
||||
if (!alive) return;
|
||||
setBook(nextBook);
|
||||
setProgress(nextProgress);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [bookId]);
|
||||
|
||||
if (!book) return <LoadingState />;
|
||||
if (loading && !book) return <LoadingState />;
|
||||
if (!book) {
|
||||
return (
|
||||
<section className="book-error-page" role="alert">
|
||||
<Panel>
|
||||
<EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
|
||||
<div className="book-card-actions">
|
||||
<button className="ghost-button" onClick={() => navigate("/home")}>
|
||||
Retour à l'accueil
|
||||
</button>
|
||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||
<RotateCcw size={17} />
|
||||
Reessayer
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const series = bookSeriesInfo(book);
|
||||
const metadataState = bookMetadataState(book);
|
||||
const publishedDate = displayPublishedDate(book.publishedDate);
|
||||
const detailFacts = [
|
||||
{ label: "Auteur", value: book.author },
|
||||
{ label: "Date", value: publishedDate },
|
||||
{ label: "Serie", value: series?.title },
|
||||
{ label: "Position", value: series?.volumeLabel },
|
||||
{ label: "Editeur", value: book.publisher },
|
||||
{ label: "ISBN", value: book.isbn13 ?? book.isbn }
|
||||
].filter((fact) => fact.value);
|
||||
|
||||
return (
|
||||
<div className="book-detail">
|
||||
@ -29,13 +81,23 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
{book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={72} />}
|
||||
</section>
|
||||
<Panel className="book-facts">
|
||||
<ErrorRibbon message={error} />
|
||||
<div className="book-card-meta">
|
||||
<FormatPill format={book.format} />
|
||||
<span>{book.language ?? "langue inconnue"}</span>
|
||||
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
|
||||
</div>
|
||||
<h1>{book.title}</h1>
|
||||
<h1>{bookDisplayTitle(book)}</h1>
|
||||
<p className="lead">{book.author ?? "Auteur inconnu"}</p>
|
||||
<p>{book.description ?? "Notice absente du catalogue."}</p>
|
||||
<dl className="book-fact-list">
|
||||
{detailFacts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="book-description">{cleanBookDescription(book.description)}</p>
|
||||
{progress && <Meter value={progress.percent} />}
|
||||
<div className="book-card-actions">
|
||||
<button className="primary-button" onClick={() => navigate(`/reader/${book.id}`)}>
|
||||
@ -46,6 +108,18 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
<LibraryBig size={18} />
|
||||
Rayon
|
||||
</button>
|
||||
{series && (
|
||||
<button className="ghost-button" onClick={() => navigate(`/catalog/series/${encodeURIComponent(series.title)}`)}>
|
||||
<LibraryBig size={18} />
|
||||
Serie
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||
<RotateCcw size={18} />
|
||||
Reessayer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
@ -1,21 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LibraryBig, ScanLine } from "lucide-react";
|
||||
import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
|
||||
import { api } from "../api/client";
|
||||
import type { DashboardData } from "../api/types";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { bookDisplayTitle, bookVolumeLabel } from "../book/metadata";
|
||||
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
|
||||
export function HomePage() {
|
||||
const [state, setState] = useState<DashboardData | null>(null);
|
||||
const [fallback, setFallback] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
Promise.all([api.books(), api.continueReading(), api.libraries(), api.jobs()])
|
||||
.then(([books, continueReading, libraries, jobs]) => {
|
||||
if (!alive) return;
|
||||
setFallback(books.some((book) => book.filePath.startsWith("/library/")) && jobs.length === 1);
|
||||
setState({ books, continueReading, libraries, jobs });
|
||||
})
|
||||
.catch(() => {
|
||||
@ -28,13 +26,32 @@ export function HomePage() {
|
||||
|
||||
if (!state) return <LoadingState />;
|
||||
|
||||
const renderHomeBook = (
|
||||
item: DashboardData["books"][number],
|
||||
className = "home-book-card",
|
||||
options: { href?: string; progressPercent?: number } = {}
|
||||
) => {
|
||||
const volumeLabel = bookVolumeLabel(item);
|
||||
return (
|
||||
<button key={item.id} className={className} onClick={() => navigate(options.href ?? `/book/${item.id}`)}>
|
||||
<span className="home-book-cover" aria-hidden="true">
|
||||
{item.coverPath ? <img src={api.bookCoverUrl(item.id)} alt="" /> : <BookOpen size={24} />}
|
||||
</span>
|
||||
<span className="home-book-copy">
|
||||
{volumeLabel && <span className="home-book-volume">{volumeLabel}</span>}
|
||||
<strong>{bookDisplayTitle(item)}</strong>
|
||||
<span>{item.author ?? "Auteur inconnu"}</span>
|
||||
{options.progressPercent !== undefined && <Meter value={options.progressPercent} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<section className="hero-band">
|
||||
<div className="page-grid home-page">
|
||||
<section className="hero-band home-hero">
|
||||
<div>
|
||||
<p>Cabinet de curiosites numerique</p>
|
||||
<h1>Ouvrir, classer, reprendre.</h1>
|
||||
<span>{fallback ? "API absente ou incomplete : specimens de demonstration actifs." : "Catalogue branche sur le serveur local."}</span>
|
||||
<h1>Reprendre la lecture.</h1>
|
||||
</div>
|
||||
<button className="primary-button" onClick={() => navigate("/search")}>
|
||||
<ScanLine size={18} />
|
||||
@ -44,18 +61,17 @@ export function HomePage() {
|
||||
|
||||
<Panel className="span-2">
|
||||
<div className="section-heading">
|
||||
<h2>Reprise de lecture</h2>
|
||||
<span>{state.continueReading.length} traces</span>
|
||||
<h2>Livres en cours</h2>
|
||||
<span>{state.continueReading.length} lectures</span>
|
||||
</div>
|
||||
{state.continueReading.length ? (
|
||||
<div className="continue-grid">
|
||||
{state.continueReading.map((item) => (
|
||||
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}>
|
||||
<strong>{item.book.title}</strong>
|
||||
<span>{item.book.author ?? "Auteur inconnu"}</span>
|
||||
<Meter value={item.progress.percent} />
|
||||
</button>
|
||||
))}
|
||||
{state.continueReading.map((item) =>
|
||||
renderHomeBook(item.book, "home-book-card continue-tile", {
|
||||
href: `/reader/${item.book.id}`,
|
||||
progressPercent: item.progress.percent
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
|
||||
@ -77,10 +93,8 @@ export function HomePage() {
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<section className="book-grid span-3">
|
||||
{state.books.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
))}
|
||||
<section className="home-book-grid span-3">
|
||||
{state.books.map((book) => renderHomeBook(book))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
62
apps/web/src/pages/HomePageProgressScroll.test.ts
Normal file
62
apps/web/src/pages/HomePageProgressScroll.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -1,19 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BookDto, LibraryDto } from "@readabook/shared";
|
||||
import type { BookDto, JobDto, LibraryDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { hasActiveCoverWork, isBookCoverUpdating } from "../api/types";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
export function LibraryPage({ libraryId }: { libraryId: number }) {
|
||||
const [books, setBooks] = useState<BookDto[] | null>(null);
|
||||
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
|
||||
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
Promise.all([api.books({ libraryId }), api.libraries()]).then(([nextBooks, nextLibraries]) => {
|
||||
Promise.all([api.books({ libraryId }), api.libraries(), api.jobs().catch(() => [])]).then(([nextBooks, nextLibraries, nextJobs]) => {
|
||||
if (!alive) return;
|
||||
setBooks(nextBooks);
|
||||
setLibraries(nextLibraries);
|
||||
setJobs(nextJobs);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
@ -22,6 +25,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) {
|
||||
|
||||
if (!books) return <LoadingState />;
|
||||
const library = libraries.find((item) => item.id === libraryId);
|
||||
const fallbackCoverLoading = hasActiveCoverWork(jobs);
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
@ -37,7 +41,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) {
|
||||
{books.length ? (
|
||||
<section className="book-grid span-3">
|
||||
{books.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
|
||||
@ -1,14 +1,36 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { KeyRound, LogIn } from "lucide-react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { KeyRound, LogIn, ShieldAlert } from "lucide-react";
|
||||
import type { AuthStatusDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { loginErrorMessage } from "../auth/errors";
|
||||
import { navigate } from "../router";
|
||||
import { ErrorRibbon, Panel } from "../components/ui";
|
||||
|
||||
const DEFAULT_INITIAL_PASSWORD = "readabook-admin-change-me";
|
||||
|
||||
export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<void> }) {
|
||||
const [email, setEmail] = useState("admin@readabook.local");
|
||||
const [status, setStatus] = useState<AuthStatusDto | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.authStatus()
|
||||
.then((nextStatus) => {
|
||||
if (!alive) return;
|
||||
setStatus(nextStatus);
|
||||
setEmail((current) => current || nextStatus.initialAdminEmail);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setError("Statut d'authentification indisponible.");
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(undefined);
|
||||
@ -17,7 +39,7 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
|
||||
await onSessionChange();
|
||||
navigate("/home");
|
||||
} catch (loginError) {
|
||||
setError(loginError instanceof Error ? loginError.message : "Connexion impossible");
|
||||
setError(loginErrorMessage(loginError));
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,6 +54,23 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
|
||||
<KeyRound size={24} />
|
||||
<h2>Entrer dans le cabinet</h2>
|
||||
<ErrorRibbon message={error} />
|
||||
{status?.hasUsers && (
|
||||
<div className="initial-admin-box">
|
||||
<ShieldAlert size={18} />
|
||||
<div>
|
||||
<strong>Acces admin initial</strong>
|
||||
<span>{status.initialAdminEmail}</span>
|
||||
{status.initialAdminPasswordIsDefault ? (
|
||||
<>
|
||||
<code>{DEFAULT_INITIAL_PASSWORD}</code>
|
||||
<small>Mot de passe par defaut atteste par le serveur. Change-le dans Mon compte > Securite.</small>
|
||||
</>
|
||||
) : (
|
||||
<small>Utilise le mot de passe configure au demarrage ou deja modifie dans le compte.</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={submit} className="stack-form">
|
||||
<label>
|
||||
Email
|
||||
@ -46,9 +85,11 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
|
||||
Se connecter
|
||||
</button>
|
||||
</form>
|
||||
<button className="ghost-button full-width" onClick={() => navigate("/setup/admin")}>
|
||||
Initialiser le premier admin
|
||||
</button>
|
||||
{status && !status.hasUsers && (
|
||||
<button className="ghost-button full-width" onClick={() => navigate("/setup/admin")}>
|
||||
Initialiser le premier admin
|
||||
</button>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,27 +1,95 @@
|
||||
import { LogOut, UserRound } from "lucide-react";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { KeyRound, LogOut, UserRound } from "lucide-react";
|
||||
import type { Session } from "../api/types";
|
||||
import { api } from "../api/client";
|
||||
import { Panel } from "../components/ui";
|
||||
import { ErrorRibbon, Panel } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
|
||||
export function ProfilePage({ session, onSessionChange }: { session: Session; onSessionChange: () => Promise<void> }) {
|
||||
const [email, setEmail] = useState(session.user?.email ?? "");
|
||||
const [name, setName] = useState(session.user?.name ?? "");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [error, setError] = useState<string>();
|
||||
const [success, setSuccess] = useState<string>();
|
||||
|
||||
async function logout() {
|
||||
await api.logout();
|
||||
await onSessionChange();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
async function updateSecurity(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(undefined);
|
||||
setSuccess(undefined);
|
||||
try {
|
||||
await api.updateMe({
|
||||
email: email === session.user?.email ? undefined : email,
|
||||
name: name || null,
|
||||
currentPassword,
|
||||
newPassword: newPassword || undefined
|
||||
});
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setSuccess("Identifiants mis a jour.");
|
||||
await onSessionChange();
|
||||
} catch (updateError) {
|
||||
setError(updateError instanceof Error ? updateError.message : "Mise a jour impossible");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Panel className="span-2 profile-panel">
|
||||
<UserRound size={28} />
|
||||
<h1>{session.user?.name ?? "Lecteur invite"}</h1>
|
||||
<p>{session.user?.email ?? "Session non connectee"}</p>
|
||||
<span>{session.user?.role ?? "vitrine"}</span>
|
||||
<button className="ghost-button" onClick={logout}>
|
||||
<LogOut size={17} />
|
||||
Sortir
|
||||
</button>
|
||||
<Panel className="profile-panel">
|
||||
<div className="profile-identity">
|
||||
<span className="profile-avatar" aria-hidden="true">
|
||||
<UserRound size={28} />
|
||||
</span>
|
||||
<h1>{session.user?.name ?? "Lecteur invite"}</h1>
|
||||
<p>{session.user?.email ?? "Session non connectee"}</p>
|
||||
<span>{session.user?.role ?? "vitrine"}</span>
|
||||
</div>
|
||||
<div className="profile-actions">
|
||||
<button className="ghost-button" onClick={() => document.getElementById("profile-security-form")?.scrollIntoView({ block: "start" })}>
|
||||
<KeyRound size={17} />
|
||||
Modifier
|
||||
</button>
|
||||
<button className="ghost-button" onClick={logout}>
|
||||
<LogOut size={17} />
|
||||
Sortir
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel className="span-2">
|
||||
<div className="section-heading">
|
||||
<h2>Securite</h2>
|
||||
<KeyRound size={20} />
|
||||
</div>
|
||||
<p className="muted-copy">Change l'email et le mot de passe admin initial des que le cabinet est installe.</p>
|
||||
<ErrorRibbon message={error} />
|
||||
{success && <div className="success-ribbon">{success}</div>}
|
||||
<form id="profile-security-form" className="stack-form" onSubmit={updateSecurity}>
|
||||
<label>
|
||||
Email
|
||||
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required />
|
||||
</label>
|
||||
<label>
|
||||
Nom
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Mot de passe actuel
|
||||
<input value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} type="password" required />
|
||||
</label>
|
||||
<label>
|
||||
Nouveau mot de passe
|
||||
<input value={newPassword} onChange={(event) => setNewPassword(event.target.value)} type="password" minLength={8} />
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
Enregistrer
|
||||
</button>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
|
||||
17
apps/web/src/pages/ProfilePagePolish.test.ts
Normal file
17
apps/web/src/pages/ProfilePagePolish.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -1,57 +1,306 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { LoadingState, Meter } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
import { CbzReader } from "../reader/CbzReader";
|
||||
import { EpubReader } from "../reader/EpubReader";
|
||||
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
||||
import { PdfReader } from "../reader/PdfReader";
|
||||
import { ReaderShell, type ReaderControls, type ReaderModeControls, type ReaderZoomAnchor, type ReaderZoomControls } from "../reader/ReaderShell";
|
||||
import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reader/readerLayout";
|
||||
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
|
||||
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
||||
import { useReaderProgress } from "../reader/useReaderProgress";
|
||||
import { navigate } from "../router";
|
||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
const idleControls: ReaderControls = {
|
||||
canPrevious: false,
|
||||
canNext: false,
|
||||
positionLabel: "Chargement",
|
||||
onPrevious: () => undefined,
|
||||
onNext: () => undefined
|
||||
};
|
||||
|
||||
type ReaderCrashBoundaryProps = {
|
||||
resetKey: string;
|
||||
onError: (error: Error) => void;
|
||||
fallbackRender: (error: Error, retry: () => void) => ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type ReaderCrashBoundaryState = {
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
class ReaderCrashBoundary extends Component<ReaderCrashBoundaryProps, ReaderCrashBoundaryState> {
|
||||
state: ReaderCrashBoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, _errorInfo: ErrorInfo) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
|
||||
componentDidUpdate(previousProps: ReaderCrashBoundaryProps) {
|
||||
if (previousProps.resetKey !== this.props.resetKey && this.state.error) {
|
||||
this.setState({ error: null });
|
||||
}
|
||||
}
|
||||
|
||||
retry = () => {
|
||||
this.setState({ error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.error) return this.props.fallbackRender(this.state.error, this.retry);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
const [book, setBook] = useState<BookDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
const [page, setPage] = useState(1);
|
||||
const { progress, saving, save } = useReaderProgress(bookId);
|
||||
const [zoom, setZoom] = useState(READER_ZOOM_DEFAULT);
|
||||
const [mode, setMode] = useState<ReaderMode>("horizontal");
|
||||
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
|
||||
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
|
||||
const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId);
|
||||
|
||||
async function loadBook() {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
setBook(await api.book(bookId));
|
||||
} catch {
|
||||
setBook(null);
|
||||
setError("Ce livre n'existe pas dans le catalogue.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.book(bookId).then(setBook);
|
||||
void loadBook();
|
||||
}, [bookId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress?.locator.startsWith("pdf:page:")) setPage(Number(progress.locator.split(":").at(-1)) || 1);
|
||||
setReaderControls(idleControls);
|
||||
setPage(1);
|
||||
setZoom(READER_ZOOM_DEFAULT);
|
||||
setMode("horizontal");
|
||||
}, [bookId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr") setMode(preferences.mode);
|
||||
else setMode("horizontal");
|
||||
}, [book?.format, preferences.mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator);
|
||||
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
||||
}, [progress]);
|
||||
|
||||
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
|
||||
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
|
||||
const savePdfPage = useCallback(
|
||||
(nextPage: number, pages: number) => {
|
||||
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
||||
setPage(nextPage);
|
||||
void save(`pdf:page:${nextPage}`, Math.round((nextPage / pages) * 100));
|
||||
const locator = pageLocator("pdf", nextPage, anchor);
|
||||
const percent = pdfPagePercent(nextPage, pages, anchor);
|
||||
if (strategy === "queued") queueSave(locator, percent);
|
||||
else void save(locator, percent);
|
||||
},
|
||||
[save]
|
||||
[queueSave, save]
|
||||
);
|
||||
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
|
||||
const saveComicPage = useCallback(
|
||||
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
||||
setPage(nextPage);
|
||||
const prefix = book?.format === "cbr" ? "cbr" : "cbz";
|
||||
const locator = pageLocator(prefix, nextPage, anchor);
|
||||
const percent = pdfPagePercent(nextPage, pages, anchor);
|
||||
if (strategy === "queued") queueSave(locator, percent);
|
||||
else void save(locator, percent);
|
||||
},
|
||||
[book?.format, queueSave, save]
|
||||
);
|
||||
|
||||
if (!book) return <LoadingState label="Ouverture du lecteur" />;
|
||||
const readerError = error ?? progressError ?? preferencesError;
|
||||
const supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr";
|
||||
const supportsMode = supportsZoom;
|
||||
const currentVisiblePage = useCallback(() => {
|
||||
const stage = document.querySelector(".reader-stage") as HTMLElement | null;
|
||||
if (!stage) return null;
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
const pages = Array.from(stage.querySelectorAll<HTMLElement>("[data-reader-page]"))
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const pageNumber = Number(element.dataset.readerPage);
|
||||
return Number.isFinite(pageNumber) ? { page: pageNumber, top: rect.top, bottom: rect.bottom } : null;
|
||||
})
|
||||
.filter((item): item is { page: number; top: number; bottom: number } => Boolean(item));
|
||||
return majorityVisiblePage(pages, stageRect.top, stageRect.bottom);
|
||||
}, []);
|
||||
const changeMode = useCallback(
|
||||
(nextMode: ReaderMode) => {
|
||||
const anchorPage = currentVisiblePage() ?? page;
|
||||
setPage(anchorPage);
|
||||
setMode(nextMode);
|
||||
saveReaderMode(nextMode);
|
||||
},
|
||||
[currentVisiblePage, page, saveReaderMode]
|
||||
);
|
||||
const returnToPagedMode = useCallback(() => {
|
||||
setPage(currentVisiblePage() ?? page);
|
||||
setMode("horizontal");
|
||||
saveReaderMode("horizontal");
|
||||
}, [currentVisiblePage, page, saveReaderMode]);
|
||||
const changeZoom = useCallback((nextZoom: number | ((currentZoom: number) => number), anchor?: ReaderZoomAnchor) => {
|
||||
const stage = document.querySelector(".reader-stage") as HTMLElement | null;
|
||||
const scrollRatioX = stage && stage.scrollWidth > stage.clientWidth ? (stage.scrollLeft + stage.clientWidth / 2) / stage.scrollWidth : 0.5;
|
||||
const scrollRatioY = stage && stage.scrollHeight > stage.clientHeight ? stage.scrollTop / (stage.scrollHeight - stage.clientHeight) : 0;
|
||||
|
||||
setZoom((currentZoom) => clampReaderZoom(typeof nextZoom === "function" ? nextZoom(currentZoom) : nextZoom));
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!stage) return;
|
||||
if (anchor) {
|
||||
const target = stage.querySelector<HTMLElement>(`[data-reader-page="${anchor.page}"]`);
|
||||
if (!target) return;
|
||||
const rect = target.getBoundingClientRect();
|
||||
stage.scrollLeft += rect.left + anchor.offsetX - anchor.clientX;
|
||||
stage.scrollTop += rect.top + anchor.offsetY - anchor.clientY;
|
||||
return;
|
||||
}
|
||||
stage.scrollLeft = Math.max(0, stage.scrollWidth * scrollRatioX - stage.clientWidth / 2);
|
||||
stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) * scrollRatioY);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
const zoomControls = useMemo<ReaderZoomControls | undefined>(
|
||||
() =>
|
||||
supportsZoom
|
||||
? {
|
||||
zoom,
|
||||
onZoomChange: (nextZoom, anchor) => changeZoom(nextZoom, anchor),
|
||||
onZoomOut: () => changeZoom((currentZoom) => currentZoom - READER_ZOOM_STEP),
|
||||
onZoomIn: () => changeZoom((currentZoom) => currentZoom + READER_ZOOM_STEP),
|
||||
onZoomReset: () => changeZoom(READER_ZOOM_DEFAULT)
|
||||
}
|
||||
: undefined,
|
||||
[changeZoom, supportsZoom, zoom]
|
||||
);
|
||||
const modeControls = useMemo<ReaderModeControls | undefined>(
|
||||
() =>
|
||||
supportsMode
|
||||
? {
|
||||
mode,
|
||||
onModeChange: changeMode
|
||||
}
|
||||
: undefined,
|
||||
[changeMode, mode, supportsMode]
|
||||
);
|
||||
|
||||
if (loading && !book) return <LoadingState />;
|
||||
|
||||
if (!book) {
|
||||
return (
|
||||
<section className="reader-missing-page" role="alert">
|
||||
<Panel>
|
||||
<EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
|
||||
<div className="reader-error-actions">
|
||||
<button className="ghost-button" onClick={() => navigate("/home")}>
|
||||
Retour à l'accueil
|
||||
</button>
|
||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="reader-page">
|
||||
<header className="reader-topbar">
|
||||
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}>
|
||||
<ArrowLeft size={17} />
|
||||
Fiche
|
||||
</button>
|
||||
<div>
|
||||
<strong>{book.title}</strong>
|
||||
<span>{saving ? "Sauvegarde" : "Progression synchronisee"}</span>
|
||||
</div>
|
||||
<Save size={18} />
|
||||
</header>
|
||||
<Meter value={progress?.percent ?? 0} />
|
||||
{book.format === "pdf" ? (
|
||||
<PdfReader url={fileUrl} page={page} onPageChange={savePdfPage} />
|
||||
) : (
|
||||
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} />
|
||||
)}
|
||||
</div>
|
||||
<ReaderShell
|
||||
title={book?.title ?? "Ouverture du lecteur"}
|
||||
backHref={backHref}
|
||||
error={readerError}
|
||||
onRetry={error ? () => void loadBook() : undefined}
|
||||
controls={readerControls}
|
||||
zoomControls={zoomControls}
|
||||
modeControls={modeControls}
|
||||
>
|
||||
<ReaderCrashBoundary
|
||||
resetKey={`${bookId}:${book?.format ?? "loading"}:${mode}`}
|
||||
onError={() => setReaderControls(idleControls)}
|
||||
fallbackRender={(crashError, retry) => (
|
||||
<div className="reader-error" role="alert">
|
||||
<div>
|
||||
<h2>Le lecteur a rencontré une erreur.</h2>
|
||||
<p>La page reste ouverte. Vous pouvez réessayer, revenir au mode page par page ou retourner à la fiche du livre.</p>
|
||||
</div>
|
||||
<div className="reader-error-actions">
|
||||
<button className="ghost-button" onClick={retry}>
|
||||
Réessayer
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
returnToPagedMode();
|
||||
retry();
|
||||
}}
|
||||
>
|
||||
Revenir au mode page par page
|
||||
</button>
|
||||
<button className="ghost-button" onClick={() => navigate(backHref)}>
|
||||
Retour à la fiche
|
||||
</button>
|
||||
</div>
|
||||
{crashError.message && (
|
||||
<details>
|
||||
<summary>Détail technique</summary>
|
||||
<pre>{crashError.message}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{book.format === "pdf" ? (
|
||||
<PdfReader
|
||||
url={fileUrl}
|
||||
page={page}
|
||||
backHref={backHref}
|
||||
zoom={zoom}
|
||||
mode={mode}
|
||||
onPageCommit={savePdfPage}
|
||||
onControlsChange={setReaderControls}
|
||||
/>
|
||||
) : book.format === "cbz" || book.format === "cbr" ? (
|
||||
<CbzReader
|
||||
bookId={book.id}
|
||||
page={page}
|
||||
zoom={zoom}
|
||||
mode={mode}
|
||||
onPageCommit={saveComicPage}
|
||||
onControlsChange={setReaderControls}
|
||||
/>
|
||||
) : (
|
||||
<EpubReader
|
||||
url={fileUrl}
|
||||
locator={progress?.locator}
|
||||
backHref={backHref}
|
||||
mode="horizontal"
|
||||
onLocatorChange={saveEpubLocator}
|
||||
onControlsChange={setReaderControls}
|
||||
/>
|
||||
)}
|
||||
</ReaderCrashBoundary>
|
||||
</ReaderShell>
|
||||
);
|
||||
}
|
||||
|
||||
20
apps/web/src/pages/ReaderPageCrash.test.ts
Normal file
20
apps/web/src/pages/ReaderPageCrash.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
177
apps/web/src/pages/ReaderPageVerticalRestore.test.ts
Normal file
177
apps/web/src/pages/ReaderPageVerticalRestore.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -1,22 +1,43 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import type { BookDto, JobDto } from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { hasActiveCoverWork, isBookCoverUpdating } from "../api/types";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
export function SearchPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [books, setBooks] = useState<BookDto[] | null>(null);
|
||||
const [books, setBooks] = useState<BookDto[]>([]);
|
||||
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
async function loadBooks(nextQuery = query) {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const [nextBooks, nextJobs] = await Promise.all([nextQuery.trim() ? api.search(nextQuery.trim()) : api.books(), api.jobs().catch(() => [])]);
|
||||
setBooks(nextBooks);
|
||||
setJobs(nextJobs);
|
||||
} catch (loadError) {
|
||||
const fallback = getApiFallback<BookDto[]>(loadError);
|
||||
setBooks(fallback ?? []);
|
||||
setError(fallback ? "Catalogue indisponible, affichage de secours." : "Recherche indisponible.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackCoverLoading = hasActiveCoverWork(jobs);
|
||||
|
||||
useEffect(() => {
|
||||
api.books().then(setBooks);
|
||||
void loadBooks("");
|
||||
}, []);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBooks(null);
|
||||
setBooks(query.trim() ? await api.search(query.trim()) : await api.books());
|
||||
await loadBooks(query);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -29,18 +50,29 @@ export function SearchPage() {
|
||||
Chercher
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
<div className="retry-row">
|
||||
<span>{error}</span>
|
||||
<button className="ghost-button" onClick={() => void loadBooks(query)}>
|
||||
Reessayer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
{!books ? (
|
||||
{loading && !books.length ? (
|
||||
<LoadingState />
|
||||
) : books.length ? (
|
||||
<section className="book-grid span-3">
|
||||
{books.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<Panel className="span-3">
|
||||
<EmptyState title="Aucun specimen" detail="Essaie un autre terme ou relance l'indexation." />
|
||||
<EmptyState
|
||||
title={loading ? "Recherche en cours" : "Aucun specimen"}
|
||||
detail={loading ? "Le formulaire reste disponible." : "Essaie un autre terme ou relance l'indexation."}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
53
apps/web/src/pages/SeriesPage.tsx
Normal file
53
apps/web/src/pages/SeriesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
apps/web/src/pages/adminAutomation.test.ts
Normal file
80
apps/web/src/pages/adminAutomation.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
132
apps/web/src/pages/adminAutomation.ts
Normal file
132
apps/web/src/pages/adminAutomation.ts
Normal 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 }));
|
||||
559
apps/web/src/reader/CbzReader.tsx
Normal file
559
apps/web/src/reader/CbzReader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
apps/web/src/reader/CbzReaderNaturalWidth.test.tsx
Normal file
94
apps/web/src/reader/CbzReaderNaturalWidth.test.tsx
Normal 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 } });
|
||||
});
|
||||
});
|
||||
@ -1,37 +1,156 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
||||
import type { ReaderControls } from "./ReaderShell";
|
||||
import type { ReaderMode } from "../api/types";
|
||||
|
||||
type FoliateModule = {
|
||||
EPUB?: unknown;
|
||||
default?: unknown;
|
||||
type FoliateLocation = {
|
||||
cfi?: string;
|
||||
fraction?: number;
|
||||
current?: number;
|
||||
total?: number;
|
||||
};
|
||||
|
||||
export function EpubReader({ url, locator, onLocatorChange }: { url: string; locator?: string; onLocatorChange: (locator: string, percent: number) => void }) {
|
||||
type FoliateView = HTMLElement & {
|
||||
open(input: File | Blob | string): Promise<void>;
|
||||
close(): void;
|
||||
goLeft(): Promise<void>;
|
||||
goRight(): Promise<void>;
|
||||
goTo(target: string): Promise<unknown>;
|
||||
next(): Promise<void>;
|
||||
lastLocation?: FoliateLocation;
|
||||
};
|
||||
|
||||
export function epubFileName(url: string): string {
|
||||
try {
|
||||
const base = globalThis.location?.href ?? "http://readabook.local/";
|
||||
const pathname = new URL(url, base).pathname;
|
||||
const name = pathname.split("/").filter(Boolean).at(-1);
|
||||
return name && name.includes(".") ? name : "book.epub";
|
||||
} catch {
|
||||
return "book.epub";
|
||||
}
|
||||
}
|
||||
|
||||
function locationPercent(location: FoliateLocation): number {
|
||||
if (typeof location.fraction === "number") return Math.max(0, Math.min(100, location.fraction * 100));
|
||||
if (typeof location.current === "number" && typeof location.total === "number" && location.total > 0) {
|
||||
return Math.max(0, Math.min(100, (location.current / location.total) * 100));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function EpubReader({
|
||||
url,
|
||||
locator,
|
||||
backHref,
|
||||
mode,
|
||||
onLocatorChange,
|
||||
onControlsChange
|
||||
}: {
|
||||
url: string;
|
||||
locator?: string;
|
||||
backHref: string;
|
||||
mode: ReaderMode;
|
||||
onLocatorChange: (locator: string, percent: number) => void;
|
||||
onControlsChange: (controls: ReaderControls) => void;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState("Ouverture EPUB");
|
||||
const viewRef = useRef<FoliateView | null>(null);
|
||||
const locatorRef = useRef(locator);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
onControlsChange({
|
||||
canPrevious: !loading && !error,
|
||||
canNext: !loading && !error,
|
||||
positionLabel: loading ? "Ouverture EPUB" : "Lecture integree",
|
||||
onPrevious: () => void viewRef.current?.goLeft(),
|
||||
onNext: () => void viewRef.current?.goRight()
|
||||
});
|
||||
}, [error, loading, onControlsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
locatorRef.current = locator;
|
||||
}, [locator]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let view: FoliateView | null = null;
|
||||
|
||||
async function mount() {
|
||||
try {
|
||||
const module = (await import("foliate-js/epub.js")) as FoliateModule;
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
await import("foliate-js/view.js");
|
||||
if (cancelled || !hostRef.current) return;
|
||||
hostRef.current.dataset.engine = module.EPUB || module.default ? "foliate-js" : "fallback";
|
||||
setStatus("EPUB pret");
|
||||
onLocatorChange(locator ?? "epub:start", locator ? 35 : 1);
|
||||
} catch {
|
||||
setStatus("Apercu EPUB indisponible dans ce navigateur");
|
||||
|
||||
const response = await fetch(url, { credentials: "include" });
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
||||
const blob = await response.blob();
|
||||
if (cancelled || !hostRef.current) return;
|
||||
|
||||
view = document.createElement("foliate-view") as FoliateView;
|
||||
view.classList.add("epub-view");
|
||||
view.addEventListener("relocate", (event) => {
|
||||
const location = (event as CustomEvent<FoliateLocation>).detail;
|
||||
if (location?.cfi) onLocatorChange(location.cfi, locationPercent(location));
|
||||
});
|
||||
hostRef.current.replaceChildren(view);
|
||||
viewRef.current = view;
|
||||
|
||||
const file = new File([blob], epubFileName(url), { type: blob.type || "application/epub+zip" });
|
||||
await view.open(file);
|
||||
if (cancelled) return;
|
||||
if (locatorRef.current) await view.goTo(locatorRef.current);
|
||||
else await view.next();
|
||||
setLoading(false);
|
||||
} catch (mountError) {
|
||||
if (!cancelled) {
|
||||
setError(readerErrorMessage(mountError, "EPUB indisponible"));
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
mount();
|
||||
|
||||
void mount();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
view?.close?.();
|
||||
view?.remove();
|
||||
if (viewRef.current === view) viewRef.current = null;
|
||||
};
|
||||
}, [locator, onLocatorChange, url]);
|
||||
}, [attempt, onLocatorChange, url]);
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical");
|
||||
viewRef.current?.classList.toggle("epub-view-horizontal", mode === "horizontal");
|
||||
}, [mode]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="epub-reader">
|
||||
<ReaderError
|
||||
title="Lecture EPUB indisponible"
|
||||
detail="ReadaBook n'a pas pu ouvrir ce fichier dans le lecteur web."
|
||||
technicalDetail={error}
|
||||
downloadUrl={url}
|
||||
backHref={backHref}
|
||||
onRetry={() => setAttempt((value) => value + 1)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="epub-reader" ref={hostRef}>
|
||||
<iframe title="EPUB" src={url} />
|
||||
<div className="reader-fallback">{status}</div>
|
||||
<div className={`epub-reader epub-reader-${mode}`}>
|
||||
{loading && (
|
||||
<div className="reader-fallback">
|
||||
<span>Ouverture EPUB</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="epub-host" ref={hostRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,56 +1,630 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as pdfjs from "pdfjs-dist";
|
||||
import workerUrl from "pdfjs-dist/build/pdf.worker.mjs?url";
|
||||
import { ReaderError } from "./ReaderError";
|
||||
import { pdfDocumentOptions } from "./pdfDocumentOptions";
|
||||
import { classifyPdfCanvas, pdfCanvasVisible, pdfRenderScale, pdfSentinelBackground, type PdfRenderResult } from "./pdfRender";
|
||||
import { configurePdfWorker } from "./pdfWorker";
|
||||
import { clampReaderPage, readableViewportSize, readerPositionLabel, readerZoomFactor, type ReaderSize } from "./readerLayout";
|
||||
import { isPageInRenderWindow, majorityVisiblePage, type ReaderMode } from "./readerScroll";
|
||||
import type { ReaderControls } from "./ReaderShell";
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
configurePdfWorker(pdfjs);
|
||||
|
||||
export function PdfReader({ url, page, onPageChange }: { url: string; page: number; onPageChange: (page: number, pages: number) => void }) {
|
||||
type PageCommitStrategy = "immediate" | "queued";
|
||||
|
||||
type PdfReaderProps = {
|
||||
url: string;
|
||||
page: number;
|
||||
backHref: string;
|
||||
zoom: number;
|
||||
mode: ReaderMode;
|
||||
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
|
||||
onControlsChange: (controls: ReaderControls) => void;
|
||||
};
|
||||
|
||||
type PdfFailure = {
|
||||
detail: string;
|
||||
technicalDetail: string;
|
||||
};
|
||||
|
||||
type PdfPageStatus = "idle" | "loading" | PdfRenderResult | "render-failed";
|
||||
|
||||
const VERTICAL_ANCHOR_TOLERANCE_PX = 24;
|
||||
const VERTICAL_ANCHOR_STABLE_FRAMES = 18;
|
||||
const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180;
|
||||
const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180;
|
||||
|
||||
function pdfTechnicalMessage(error: unknown) {
|
||||
if (error instanceof Error && error.message.trim()) return `${error.name}: ${error.message}`;
|
||||
if (typeof error === "string" && error.trim()) return error;
|
||||
return "Erreur inconnue.";
|
||||
}
|
||||
|
||||
function pdfFailure(source: string, detail: string, context: Record<string, unknown>, error?: unknown): PdfFailure {
|
||||
return {
|
||||
detail,
|
||||
technicalDetail: JSON.stringify(
|
||||
{
|
||||
source,
|
||||
...context,
|
||||
error: error === undefined ? undefined : pdfTechnicalMessage(error)
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function isPdfTransitionError(error: unknown) {
|
||||
return error instanceof Error && (error.message.includes("worker is being destroyed") || error.message.includes("Rendering cancelled"));
|
||||
}
|
||||
|
||||
function scrollContainerFor(element: HTMLElement | null): HTMLElement | null {
|
||||
return element?.closest(".reader-stage") as HTMLElement | null;
|
||||
}
|
||||
|
||||
function afterNextPaint() {
|
||||
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
function PdfPageCanvas({
|
||||
documentProxy,
|
||||
pageNumber,
|
||||
viewportSize,
|
||||
zoom,
|
||||
active
|
||||
}: {
|
||||
documentProxy: pdfjs.PDFDocumentProxy;
|
||||
pageNumber: number;
|
||||
viewportSize: ReaderSize | null;
|
||||
zoom: number;
|
||||
active: boolean;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [error, setError] = useState<string>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rendered, setRendered] = useState(false);
|
||||
const [pageStatus, setPageStatus] = useState<PdfPageStatus>("idle");
|
||||
const [pageError, setPageError] = useState<string>();
|
||||
const showCanvas = active && pdfCanvasVisible(rendered, Boolean(pageError));
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !viewportSize) return;
|
||||
const readerViewport = viewportSize;
|
||||
let cancelled = false;
|
||||
async function render() {
|
||||
let renderTask: pdfjs.RenderTask | undefined;
|
||||
setLoading(true);
|
||||
setRendered(false);
|
||||
setPageStatus("loading");
|
||||
setPageError(undefined);
|
||||
|
||||
async function renderPage() {
|
||||
try {
|
||||
const loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
||||
const document = await loadingTask.promise;
|
||||
const pdfPage = await documentProxy.getPage(pageNumber);
|
||||
if (cancelled) return;
|
||||
setPages(document.numPages);
|
||||
const pdfPage = await document.getPage(Math.max(1, Math.min(page, document.numPages)));
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const viewport = pdfPage.getViewport({ scale: Math.min(1.6, window.devicePixelRatio || 1) });
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
await pdfPage.render({ canvas, canvasContext: context, viewport }).promise;
|
||||
onPageChange(Math.max(1, Math.min(page, document.numPages)), document.numPages);
|
||||
} catch (renderError) {
|
||||
setError(renderError instanceof Error ? renderError.message : "PDF indisponible");
|
||||
const baseViewport = pdfPage.getViewport({ scale: 1 });
|
||||
const fitScale = pdfRenderScale(readerViewport, { width: baseViewport.width, height: baseViewport.height });
|
||||
const pixelRatio = Math.min(2, window.devicePixelRatio || 1);
|
||||
const viewport = pdfPage.getViewport({ scale: Math.max(0.25, fitScale * readerZoomFactor(zoom)) * pixelRatio });
|
||||
const cssWidth = Math.floor(viewport.width / pixelRatio);
|
||||
const cssHeight = Math.floor(viewport.height / pixelRatio);
|
||||
canvas.width = Math.max(1, Math.floor(viewport.width));
|
||||
canvas.height = Math.max(1, Math.floor(viewport.height));
|
||||
canvas.style.width = `${cssWidth}px`;
|
||||
canvas.style.height = `${cssHeight}px`;
|
||||
const context = canvas.getContext("2d", { alpha: false });
|
||||
if (!context) throw new Error("PDF canvas context unavailable");
|
||||
context.fillStyle = pdfSentinelBackground();
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
|
||||
await renderTask.promise;
|
||||
await afterNextPaint();
|
||||
if (cancelled) return;
|
||||
setPageStatus(classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height)));
|
||||
setRendered(true);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
if (cancelled || isPdfTransitionError(error)) return;
|
||||
setPageStatus("render-failed");
|
||||
setLoading(false);
|
||||
setPageError(`Page ${pageNumber} indisponible.`);
|
||||
}
|
||||
}
|
||||
render();
|
||||
|
||||
void renderPage();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
renderTask?.cancel();
|
||||
};
|
||||
}, [url, page, onPageChange]);
|
||||
}, [active, documentProxy, pageNumber, viewportSize, zoom]);
|
||||
|
||||
return (
|
||||
<div className="pdf-reader">
|
||||
{error ? <div className="reader-fallback">{error}</div> : <canvas ref={canvasRef} />}
|
||||
<div className="reader-stepper">
|
||||
<button className="ghost-button" onClick={() => onPageChange(Math.max(1, page - 1), pages)}>
|
||||
Precedent
|
||||
</button>
|
||||
<span>
|
||||
{page} / {pages}
|
||||
</span>
|
||||
<button className="ghost-button" onClick={() => onPageChange(Math.min(pages, page + 1), pages)}>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
<div className="pdf-page-frame pdf-page-frame-vertical" data-reader-page={pageNumber}>
|
||||
{(!active || !viewportSize || loading || !rendered || pageError) && (
|
||||
<div className={`reader-fallback${pageError ? " reader-fallback-error" : ""}`}>
|
||||
<span>{pageError ?? `Page ${pageNumber}`}</span>
|
||||
</div>
|
||||
)}
|
||||
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onControlsChange }: PdfReaderProps) {
|
||||
const frameRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const previousModeRef = useRef<ReaderMode | null>(null);
|
||||
const pendingVerticalAnchorRef = useRef(false);
|
||||
const verticalTrackingReadyRef = useRef(false);
|
||||
const verticalUserScrollRef = useRef(false);
|
||||
const verticalAnchorTargetPageRef = useRef<number | null>(null);
|
||||
const verticalAnchorFrameRef = useRef<number | null>(null);
|
||||
const verticalInitialSyncFrameRef = useRef<number | null>(null);
|
||||
const verticalInitialSyncAttemptRef = useRef(0);
|
||||
const verticalInitialSyncDoneRef = useRef(false);
|
||||
const verticalAnchorAttemptRef = useRef(0);
|
||||
const verticalAnchorStableFramesRef = useRef(0);
|
||||
const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [documentError, setDocumentError] = useState<PdfFailure>();
|
||||
const [pageError, setPageError] = useState<PdfFailure>();
|
||||
const [loadingDocument, setLoadingDocument] = useState(true);
|
||||
const [loadingPage, setLoadingPage] = useState(false);
|
||||
const [pageRendered, setPageRendered] = useState(false);
|
||||
const [pageStatus, setPageStatus] = useState<PdfPageStatus>("idle");
|
||||
const [documentAttempt, setDocumentAttempt] = useState(0);
|
||||
const [pageAttempt, setPageAttempt] = useState(0);
|
||||
const [viewportSize, setViewportSize] = useState<ReaderSize | null>(null);
|
||||
|
||||
const currentPage = clampReaderPage(page, pages);
|
||||
const showCanvas = pdfCanvasVisible(pageRendered, Boolean(pageError));
|
||||
const clearVerticalAnchorFrame = useCallback(() => {
|
||||
if (verticalAnchorFrameRef.current !== null) cancelAnimationFrame(verticalAnchorFrameRef.current);
|
||||
verticalAnchorFrameRef.current = null;
|
||||
}, []);
|
||||
const clearVerticalInitialSync = useCallback(() => {
|
||||
if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current);
|
||||
verticalInitialSyncFrameRef.current = null;
|
||||
}, []);
|
||||
const commitVisiblePage = useCallback(
|
||||
(stage: HTMLElement, allowInitialCommit = false) => {
|
||||
if (!verticalTrackingReadyRef.current) return;
|
||||
if (stage.dataset.readerPinchActive === "true") return;
|
||||
if (!allowInitialCommit && !verticalUserScrollRef.current) return;
|
||||
if (allowInitialCommit && verticalInitialSyncDoneRef.current) return;
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
const visiblePage = majorityVisiblePage(
|
||||
Array.from(stage.querySelectorAll<HTMLElement>("[data-reader-page]")).map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { page: Number(element.dataset.readerPage), top: rect.top, bottom: rect.bottom };
|
||||
}),
|
||||
stageRect.top,
|
||||
stageRect.bottom
|
||||
);
|
||||
if (visiblePage && visiblePage !== currentPage) {
|
||||
if (allowInitialCommit) verticalInitialSyncDoneRef.current = true;
|
||||
onPageCommit(clampReaderPage(visiblePage, pages), pages, 1, "queued");
|
||||
}
|
||||
},
|
||||
[currentPage, onPageCommit, pages]
|
||||
);
|
||||
const scheduleInitialVisibleSync = useCallback(
|
||||
(stage: HTMLElement) => {
|
||||
clearVerticalInitialSync();
|
||||
verticalInitialSyncAttemptRef.current = 0;
|
||||
|
||||
const sync = () => {
|
||||
verticalInitialSyncFrameRef.current = null;
|
||||
if (verticalUserScrollRef.current || verticalInitialSyncDoneRef.current) return;
|
||||
if (verticalTrackingReadyRef.current && stage.scrollHeight > stage.clientHeight && stage.scrollTop > Math.max(32, stage.clientHeight * 0.5)) {
|
||||
commitVisiblePage(stage, true);
|
||||
}
|
||||
verticalInitialSyncAttemptRef.current += 1;
|
||||
if (verticalInitialSyncAttemptRef.current >= VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS) return;
|
||||
verticalInitialSyncFrameRef.current = requestAnimationFrame(sync);
|
||||
};
|
||||
|
||||
verticalInitialSyncFrameRef.current = requestAnimationFrame(sync);
|
||||
},
|
||||
[clearVerticalInitialSync, commitVisiblePage]
|
||||
);
|
||||
const stabilizeVerticalAnchor = useCallback(
|
||||
(targetPage: number) => {
|
||||
clearVerticalAnchorFrame();
|
||||
verticalAnchorAttemptRef.current = 0;
|
||||
verticalAnchorStableFramesRef.current = 0;
|
||||
verticalTrackingReadyRef.current = false;
|
||||
|
||||
const measure = () => {
|
||||
const frame = frameRef.current;
|
||||
const stage = scrollContainerFor(frame);
|
||||
const target = frame?.querySelector<HTMLElement>(`[data-reader-page="${targetPage}"]`);
|
||||
if (!stage || !target) return;
|
||||
|
||||
target.scrollIntoView({ block: "start" });
|
||||
verticalAnchorFrameRef.current = requestAnimationFrame(() => {
|
||||
verticalAnchorFrameRef.current = null;
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const aligned = Math.abs(targetRect.top - stageRect.top) <= VERTICAL_ANCHOR_TOLERANCE_PX;
|
||||
const cannotScrollFurther = stage.scrollTop + stage.clientHeight >= stage.scrollHeight - 2;
|
||||
verticalAnchorStableFramesRef.current = aligned || cannotScrollFurther ? verticalAnchorStableFramesRef.current + 1 : 0;
|
||||
|
||||
if (verticalAnchorStableFramesRef.current >= VERTICAL_ANCHOR_STABLE_FRAMES) {
|
||||
verticalTrackingReadyRef.current = true;
|
||||
commitVisiblePage(stage, true);
|
||||
scheduleInitialVisibleSync(stage);
|
||||
return;
|
||||
}
|
||||
verticalAnchorAttemptRef.current += 1;
|
||||
if (verticalAnchorAttemptRef.current >= VERTICAL_ANCHOR_MAX_ATTEMPTS) return;
|
||||
verticalAnchorFrameRef.current = requestAnimationFrame(measure);
|
||||
});
|
||||
};
|
||||
|
||||
verticalAnchorFrameRef.current = requestAnimationFrame(measure);
|
||||
},
|
||||
[clearVerticalAnchorFrame, commitVisiblePage, scheduleInitialVisibleSync]
|
||||
);
|
||||
|
||||
const go = useCallback(
|
||||
(nextPage: number) => {
|
||||
const target = clampReaderPage(nextPage, pages);
|
||||
setPageError(undefined);
|
||||
if (mode === "vertical") {
|
||||
verticalUserScrollRef.current = false;
|
||||
frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" });
|
||||
}
|
||||
onPageCommit(target, pages, 1, "immediate");
|
||||
},
|
||||
[mode, onPageCommit, pages]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onControlsChange({
|
||||
canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1,
|
||||
canNext: !loadingDocument && !documentError && !pageError && currentPage < pages,
|
||||
positionLabel: loadingDocument ? "Chargement" : readerPositionLabel(currentPage, pages),
|
||||
onPrevious: () => go(currentPage - 1),
|
||||
onNext: () => go(currentPage + 1)
|
||||
});
|
||||
}, [currentPage, documentError, go, loadingDocument, onControlsChange, pageError, pages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "vertical") {
|
||||
previousModeRef.current = mode;
|
||||
pendingVerticalAnchorRef.current = false;
|
||||
verticalTrackingReadyRef.current = false;
|
||||
verticalUserScrollRef.current = false;
|
||||
verticalAnchorTargetPageRef.current = null;
|
||||
verticalInitialSyncDoneRef.current = false;
|
||||
clearVerticalAnchorFrame();
|
||||
clearVerticalInitialSync();
|
||||
return;
|
||||
}
|
||||
const enteringVertical = previousModeRef.current !== "vertical";
|
||||
if (enteringVertical || (!verticalUserScrollRef.current && verticalAnchorTargetPageRef.current !== currentPage)) {
|
||||
pendingVerticalAnchorRef.current = true;
|
||||
verticalTrackingReadyRef.current = false;
|
||||
verticalUserScrollRef.current = false;
|
||||
if (enteringVertical) verticalInitialSyncDoneRef.current = false;
|
||||
clearVerticalAnchorFrame();
|
||||
clearVerticalInitialSync();
|
||||
}
|
||||
if (pendingVerticalAnchorRef.current) {
|
||||
const target = frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`);
|
||||
if (!target) {
|
||||
previousModeRef.current = mode;
|
||||
return;
|
||||
}
|
||||
pendingVerticalAnchorRef.current = false;
|
||||
verticalAnchorTargetPageRef.current = currentPage;
|
||||
stabilizeVerticalAnchor(currentPage);
|
||||
}
|
||||
previousModeRef.current = mode;
|
||||
}, [clearVerticalAnchorFrame, clearVerticalInitialSync, currentPage, documentProxy, mode, stabilizeVerticalAnchor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "vertical") return;
|
||||
const stage = scrollContainerFor(frameRef.current);
|
||||
if (!stage) return;
|
||||
let frameId = 0;
|
||||
const markUserScroll = () => {
|
||||
if (stage.dataset.readerPinchActive === "true") return;
|
||||
verticalUserScrollRef.current = true;
|
||||
};
|
||||
const markUserScrollKey = (event: KeyboardEvent) => {
|
||||
if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"].includes(event.key)) markUserScroll();
|
||||
};
|
||||
const keyTarget = typeof window === "undefined" ? null : window;
|
||||
const updateVisiblePage = () => {
|
||||
frameId = 0;
|
||||
commitVisiblePage(stage);
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (stage.dataset.readerPinchActive === "true") return;
|
||||
if (frameId) return;
|
||||
frameId = requestAnimationFrame(updateVisiblePage);
|
||||
};
|
||||
stage.addEventListener("wheel", markUserScroll, { passive: true });
|
||||
stage.addEventListener("touchmove", markUserScroll, { passive: true });
|
||||
stage.addEventListener("pointerdown", markUserScroll, { passive: true });
|
||||
keyTarget?.addEventListener("keydown", markUserScrollKey);
|
||||
stage.addEventListener("scroll", onScroll, { passive: true });
|
||||
scheduleInitialVisibleSync(stage);
|
||||
updateVisiblePage();
|
||||
return () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
stage.removeEventListener("wheel", markUserScroll);
|
||||
stage.removeEventListener("touchmove", markUserScroll);
|
||||
stage.removeEventListener("pointerdown", markUserScroll);
|
||||
keyTarget?.removeEventListener("keydown", markUserScrollKey);
|
||||
stage.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [commitVisiblePage, mode, pages, scheduleInitialVisibleSync]);
|
||||
|
||||
useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]);
|
||||
useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current;
|
||||
const stage = scrollContainerFor(frame);
|
||||
const observedElement = stage ?? frame;
|
||||
if (!observedElement) return;
|
||||
const updateSize = () => {
|
||||
const rect = observedElement.getBoundingClientRect();
|
||||
const nextSize = readableViewportSize({ width: rect.width, height: rect.height });
|
||||
if (nextSize) setViewportSize(nextSize);
|
||||
};
|
||||
updateSize();
|
||||
const frameId = requestAnimationFrame(updateSize);
|
||||
const observer = new ResizeObserver(updateSize);
|
||||
observer.observe(observedElement);
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
|
||||
setLoadingDocument(true);
|
||||
setDocumentError(undefined);
|
||||
setPageError(undefined);
|
||||
setPageRendered(false);
|
||||
setPageStatus("idle");
|
||||
setDocumentProxy(null);
|
||||
|
||||
async function loadDocument() {
|
||||
try {
|
||||
try {
|
||||
new URL(url, window.location.href);
|
||||
} catch (error) {
|
||||
throw pdfFailure("uri", "PDF: URI de fichier invalide.", { url }, error);
|
||||
}
|
||||
configurePdfWorker(pdfjs);
|
||||
loadingTask = pdfjs.getDocument(pdfDocumentOptions({ url, withCredentials: true }, "browser"));
|
||||
const loadedDocument = await loadingTask.promise;
|
||||
if (cancelled) {
|
||||
await loadingTask.destroy();
|
||||
return;
|
||||
}
|
||||
setPages(loadedDocument.numPages);
|
||||
setDocumentProxy(loadedDocument);
|
||||
setLoadingDocument(false);
|
||||
} catch (error) {
|
||||
if (cancelled && isPdfTransitionError(error)) return;
|
||||
if (!cancelled) {
|
||||
setDocumentError(
|
||||
typeof error === "object" && error && "detail" in error && "technicalDetail" in error
|
||||
? (error as PdfFailure)
|
||||
: pdfFailure(
|
||||
"getDocument",
|
||||
"PDF: getDocument() a échoué.",
|
||||
{
|
||||
url,
|
||||
resolvedUrl: (() => {
|
||||
try {
|
||||
return new URL(url, window.location.href).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
workerSrc: pdfjs.GlobalWorkerOptions.workerSrc,
|
||||
hasWorkerPort: Boolean(pdfjs.GlobalWorkerOptions.workerPort)
|
||||
},
|
||||
error
|
||||
)
|
||||
);
|
||||
setLoadingDocument(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadDocument();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void loadingTask?.destroy();
|
||||
};
|
||||
}, [documentAttempt, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "vertical") {
|
||||
setPageError(undefined);
|
||||
return;
|
||||
}
|
||||
if (!documentProxy || !viewportSize) return;
|
||||
const loadedDocument = documentProxy;
|
||||
const readerViewport = viewportSize;
|
||||
let cancelled = false;
|
||||
let renderTask: pdfjs.RenderTask | undefined;
|
||||
setLoadingPage(true);
|
||||
setPageError(undefined);
|
||||
setPageRendered(false);
|
||||
setPageStatus("loading");
|
||||
|
||||
async function renderPage() {
|
||||
try {
|
||||
let pdfPage: pdfjs.PDFPageProxy;
|
||||
try {
|
||||
pdfPage = await loadedDocument.getPage(currentPage);
|
||||
} catch (error) {
|
||||
throw pdfFailure("getPage", `PDF: getPage(${currentPage}) a échoué.`, { pageNumber: currentPage }, error);
|
||||
}
|
||||
if (cancelled) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
throw pdfFailure("react-canvas-ref", `PDF: canvas absent pour la page ${currentPage}.`, { pageNumber: currentPage });
|
||||
}
|
||||
const baseViewport = pdfPage.getViewport({ scale: 1 });
|
||||
const fitScale = pdfRenderScale(readerViewport, { width: baseViewport.width, height: baseViewport.height });
|
||||
const pixelRatio = Math.min(2, window.devicePixelRatio || 1);
|
||||
const zoomedScale = fitScale * readerZoomFactor(zoom);
|
||||
const renderScale = Math.max(0.25, zoomedScale) * pixelRatio;
|
||||
const viewport = pdfPage.getViewport({ scale: renderScale });
|
||||
const cssWidth = Math.floor(viewport.width / pixelRatio);
|
||||
const cssHeight = Math.floor(viewport.height / pixelRatio);
|
||||
canvas.width = Math.max(1, Math.floor(viewport.width));
|
||||
canvas.height = Math.max(1, Math.floor(viewport.height));
|
||||
canvas.style.width = `${cssWidth}px`;
|
||||
canvas.style.height = `${cssHeight}px`;
|
||||
const context = canvas.getContext("2d", { alpha: false });
|
||||
if (!context) {
|
||||
throw pdfFailure("canvas-context", `PDF: contexte canvas 2D indisponible pour la page ${currentPage}.`, {
|
||||
pageNumber: currentPage,
|
||||
canvasWidth: canvas.width,
|
||||
canvasHeight: canvas.height
|
||||
});
|
||||
}
|
||||
if (canvas.width <= 0 || canvas.height <= 0 || cssWidth <= 0 || cssHeight <= 0) {
|
||||
throw pdfFailure("canvas-size", `PDF: dimensions canvas invalides pour la page ${currentPage}.`, {
|
||||
pageNumber: currentPage,
|
||||
canvasWidth: canvas.width,
|
||||
canvasHeight: canvas.height,
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
readerViewport
|
||||
});
|
||||
}
|
||||
context.fillStyle = pdfSentinelBackground();
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
try {
|
||||
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
|
||||
await renderTask.promise;
|
||||
} catch (error) {
|
||||
throw pdfFailure(
|
||||
"render-failed",
|
||||
`PDF: render() a échoué pour la page ${currentPage}.`,
|
||||
{
|
||||
result: "render-failed",
|
||||
pageNumber: currentPage,
|
||||
baseViewport: { width: baseViewport.width, height: baseViewport.height },
|
||||
renderViewport: { width: viewport.width, height: viewport.height },
|
||||
canvasWidth: canvas.width,
|
||||
canvasHeight: canvas.height,
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
readerViewport
|
||||
},
|
||||
error
|
||||
);
|
||||
}
|
||||
await afterNextPaint();
|
||||
if (cancelled) return;
|
||||
const renderResult = classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height));
|
||||
if (renderResult === "blank-detected") {
|
||||
setPageStatus("blank-detected");
|
||||
setPageRendered(true);
|
||||
setLoadingPage(false);
|
||||
return;
|
||||
}
|
||||
if (!cancelled) {
|
||||
setPageStatus("rendered-ok");
|
||||
setPageRendered(true);
|
||||
setLoadingPage(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled || isPdfTransitionError(error)) return;
|
||||
setPageRendered(false);
|
||||
setPageStatus("render-failed");
|
||||
setLoadingPage(false);
|
||||
setPageError(
|
||||
typeof error === "object" && error && "detail" in error && "technicalDetail" in error
|
||||
? (error as PdfFailure)
|
||||
: pdfFailure("render-unclassified", `PDF: erreur non classée pendant le rendu de la page ${currentPage}.`, { pageNumber: currentPage }, error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void renderPage();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
renderTask?.cancel();
|
||||
};
|
||||
}, [currentPage, documentProxy, mode, pageAttempt, viewportSize, zoom]);
|
||||
|
||||
if (documentError) {
|
||||
return (
|
||||
<div className="pdf-reader" ref={frameRef}>
|
||||
<ReaderError
|
||||
title="Lecture PDF indisponible"
|
||||
detail={documentError.detail}
|
||||
technicalDetail={documentError.technicalDetail}
|
||||
downloadUrl={url}
|
||||
backHref={backHref}
|
||||
onRetry={() => setDocumentAttempt((value) => value + 1)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`pdf-reader${mode === "vertical" ? " pdf-reader-vertical" : ""}`} ref={frameRef}>
|
||||
{loadingDocument || !documentProxy ? (
|
||||
<div className="reader-fallback">
|
||||
<span>Ouverture PDF</span>
|
||||
</div>
|
||||
) : mode === "vertical" ? (
|
||||
<div className="reader-vertical-stack">
|
||||
{Array.from({ length: pages }, (_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
return (
|
||||
<PdfPageCanvas
|
||||
key={pageNumber}
|
||||
documentProxy={documentProxy}
|
||||
pageNumber={pageNumber}
|
||||
viewportSize={viewportSize}
|
||||
zoom={zoom}
|
||||
active={isPageInRenderWindow(pageNumber, currentPage, pages)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : pageError ? (
|
||||
<div className="pdf-page-frame" data-reader-page={currentPage}>
|
||||
<div className="reader-fallback reader-fallback-error">
|
||||
<span>{pageError.detail}</span>
|
||||
<button className="ghost-button" onClick={() => setPageAttempt((value) => value + 1)}>
|
||||
Reessayer
|
||||
</button>
|
||||
<details>
|
||||
<summary>Détail technique</summary>
|
||||
<pre>{pageError.technicalDetail}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pdf-page-frame" data-reader-page={currentPage}>
|
||||
{(!viewportSize || loadingPage || !pageRendered) && (
|
||||
<div className="reader-fallback">
|
||||
<span>Page {currentPage}</span>
|
||||
</div>
|
||||
)}
|
||||
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user