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