merge: feat/metadata-automation dans develop (métadonnées multi-providers + automatisation)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,6 +6,7 @@ dist
|
|||||||
*.sqlite-*
|
*.sqlite-*
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
data/storage
|
data/storage
|
||||||
|
Books/
|
||||||
coverage
|
coverage
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
.ideai/
|
.ideai/
|
||||||
|
|||||||
14
README.md
14
README.md
@ -94,12 +94,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 +136,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
|
||||||
|
|||||||
@ -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,5 +1,6 @@
|
|||||||
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";
|
||||||
@ -8,7 +9,7 @@ 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, ScannerModule, AutomationModule],
|
||||||
controllers: [HealthController]
|
controllers: [HealthController]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
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";
|
||||||
@ -40,10 +40,17 @@ export class BooksController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException, 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 { 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";
|
||||||
@ -53,12 +53,22 @@ export class BooksService {
|
|||||||
return book;
|
return book;
|
||||||
}
|
}
|
||||||
|
|
||||||
streamFile(id: number) {
|
streamFile(id: number, range?: string) {
|
||||||
const book = this.get(id);
|
const book = this.get(id);
|
||||||
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) };
|
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) {
|
streamCover(id: number) {
|
||||||
@ -108,6 +118,38 @@ export class BooksService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseByteRange(range: string | undefined, size: number): { start: number; end: number; partial: boolean } {
|
||||||
|
if (!range) return { start: 0, end: Math.max(size - 1, 0), partial: false };
|
||||||
|
const match = range.match(/^bytes=(\d*)-(\d*)$/);
|
||||||
|
if (!match || size <= 0) {
|
||||||
|
throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, rawStart, rawEnd] = match;
|
||||||
|
let start: number;
|
||||||
|
let end: number;
|
||||||
|
if (!rawStart && rawEnd) {
|
||||||
|
const suffixLength = Number(rawEnd);
|
||||||
|
start = Math.max(size - suffixLength, 0);
|
||||||
|
end = size - 1;
|
||||||
|
} else {
|
||||||
|
start = Number(rawStart);
|
||||||
|
end = rawEnd ? Number(rawEnd) : size - 1;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= size) {
|
||||||
|
throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
|
||||||
|
}
|
||||||
|
return { start, end: Math.min(end, size - 1), partial: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookContentType(format: string): string {
|
||||||
|
if (format === "epub") return "application/epub+zip";
|
||||||
|
if (format === "pdf") return "application/pdf";
|
||||||
|
if (format === "cbz") return "application/vnd.comicbook+zip";
|
||||||
|
if (format === "cbr") return "application/vnd.comicbook-rar";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
function lookupMime(entryName: string): string {
|
function lookupMime(entryName: string): string {
|
||||||
const extension = extname(entryName).toLowerCase();
|
const extension = extname(entryName).toLowerCase();
|
||||||
if (extension === ".png") return "image/png";
|
if (extension === ".png") return "image/png";
|
||||||
@ -125,6 +167,7 @@ function mapBookRow(row: Record<string, unknown>) {
|
|||||||
author: nullable(row.author),
|
author: nullable(row.author),
|
||||||
description: nullable(row.description),
|
description: nullable(row.description),
|
||||||
isbn: nullable(row.isbn),
|
isbn: nullable(row.isbn),
|
||||||
|
isbn13: nullable(row.isbn13),
|
||||||
language: nullable(row.language),
|
language: nullable(row.language),
|
||||||
publisher: nullable(row.publisher),
|
publisher: nullable(row.publisher),
|
||||||
publishedDate: nullable(row.published_date),
|
publishedDate: nullable(row.published_date),
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export type AppConfig = {
|
|||||||
cookieName: string;
|
cookieName: string;
|
||||||
cookieSecure: boolean;
|
cookieSecure: boolean;
|
||||||
openLibraryEnabled: boolean;
|
openLibraryEnabled: boolean;
|
||||||
|
libraryPathAliases: Array<{ from: string; to: string }>;
|
||||||
initialAdminEmail: string;
|
initialAdminEmail: string;
|
||||||
initialAdminPassword: string;
|
initialAdminPassword: string;
|
||||||
initialAdminPasswordIsDefault: boolean;
|
initialAdminPasswordIsDefault: boolean;
|
||||||
@ -36,8 +37,25 @@ export function loadConfig(): AppConfig {
|
|||||||
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,
|
initialAdminEmail: process.env.INITIAL_ADMIN_EMAIL ?? DEFAULT_INITIAL_ADMIN_EMAIL,
|
||||||
initialAdminPassword: process.env.INITIAL_ADMIN_PASSWORD ?? DEFAULT_INITIAL_ADMIN_PASSWORD,
|
initialAdminPassword: process.env.INITIAL_ADMIN_PASSWORD ?? DEFAULT_INITIAL_ADMIN_PASSWORD,
|
||||||
initialAdminPasswordIsDefault: !process.env.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));
|
||||||
|
}
|
||||||
|
|||||||
118
apps/api/src/database/database.service.test.ts
Normal file
118
apps/api/src/database/database.service.test.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { DatabaseService } from "./database.service.js";
|
||||||
|
|
||||||
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
|
const previousStorageDir = process.env.STORAGE_DIR;
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.DATABASE_PATH = previousDatabasePath;
|
||||||
|
process.env.STORAGE_DIR = previousStorageDir;
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("database migrations", () => {
|
||||||
|
it.runIf(canLoadBetterSqlite())("adds metadata columns to an existing comic-capable books table before creating dependent indexes", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "readabook-migration-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const databasePath = join(dir, "readabook.sqlite");
|
||||||
|
const storageDir = join(dir, "storage");
|
||||||
|
|
||||||
|
const legacy = new Database(databasePath);
|
||||||
|
legacy.exec(`
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE libraries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE books (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
author TEXT,
|
||||||
|
description TEXT,
|
||||||
|
isbn TEXT,
|
||||||
|
language TEXT,
|
||||||
|
publisher TEXT,
|
||||||
|
published_date TEXT,
|
||||||
|
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
|
||||||
|
file_path TEXT NOT NULL UNIQUE,
|
||||||
|
cover_path TEXT,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
file_mtime TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE progress (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
|
||||||
|
locator TEXT NOT NULL,
|
||||||
|
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(user_id, book_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed')),
|
||||||
|
detail TEXT,
|
||||||
|
error TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
legacy.close();
|
||||||
|
|
||||||
|
process.env.DATABASE_PATH = databasePath;
|
||||||
|
process.env.STORAGE_DIR = storageDir;
|
||||||
|
|
||||||
|
const database = new DatabaseService();
|
||||||
|
const bookColumns = database.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>;
|
||||||
|
const bookIndexes = database.sqlite.prepare("PRAGMA index_list(books)").all() as Array<{ name: string }>;
|
||||||
|
const metadataSources = database.sqlite.prepare("SELECT provider FROM metadata_source_config ORDER BY priority").all() as Array<{ provider: string }>;
|
||||||
|
const automationSettings = database.sqlite.prepare("SELECT id, isbn_priority_enabled FROM automation_settings").get() as
|
||||||
|
| { id: number; isbn_priority_enabled: number }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
expect(bookColumns.map((column) => column.name)).toContain("isbn13");
|
||||||
|
expect(bookColumns.map((column) => column.name)).toContain("identifiers_json");
|
||||||
|
expect(bookIndexes.map((index) => index.name)).toContain("books_isbn13_idx");
|
||||||
|
expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf"]);
|
||||||
|
expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 });
|
||||||
|
|
||||||
|
database.onModuleDestroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function canLoadBetterSqlite(): boolean {
|
||||||
|
try {
|
||||||
|
new Database(":memory:").close();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -56,6 +56,8 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
author TEXT,
|
author TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
isbn TEXT,
|
isbn TEXT,
|
||||||
|
isbn13 TEXT,
|
||||||
|
identifiers_json TEXT,
|
||||||
language TEXT,
|
language TEXT,
|
||||||
publisher TEXT,
|
publisher TEXT,
|
||||||
published_date TEXT,
|
published_date TEXT,
|
||||||
@ -89,6 +91,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')),
|
||||||
|
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,
|
||||||
@ -120,6 +142,10 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
END;
|
END;
|
||||||
`);
|
`);
|
||||||
this.ensureBooksSupportsComicArchives();
|
this.ensureBooksSupportsComicArchives();
|
||||||
|
this.ensureBooksMetadataColumns();
|
||||||
|
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')");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,6 +172,8 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
author TEXT,
|
author TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
isbn TEXT,
|
isbn TEXT,
|
||||||
|
isbn13 TEXT,
|
||||||
|
identifiers_json TEXT,
|
||||||
language TEXT,
|
language TEXT,
|
||||||
publisher TEXT,
|
publisher TEXT,
|
||||||
published_date TEXT,
|
published_date TEXT,
|
||||||
@ -158,11 +186,11 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
INSERT INTO books (
|
INSERT INTO books (
|
||||||
id, library_id, title, author, description, isbn, language, publisher, published_date,
|
id, library_id, title, author, description, isbn, isbn13, identifiers_json, language, publisher, published_date,
|
||||||
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
id, library_id, title, author, description, isbn, language, publisher, published_date,
|
id, library_id, title, author, description, isbn, NULL, NULL, language, publisher, published_date,
|
||||||
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
||||||
FROM books_legacy_format;
|
FROM books_legacy_format;
|
||||||
DROP TABLE books_legacy_format;
|
DROP TABLE books_legacy_format;
|
||||||
@ -174,6 +202,7 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS books_file_path_unique ON books(file_path);
|
CREATE 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_library_idx ON books(library_id);
|
||||||
CREATE INDEX IF NOT EXISTS books_title_idx ON books(title);
|
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
|
CREATE TRIGGER IF NOT EXISTS books_ai AFTER INSERT ON books BEGIN
|
||||||
INSERT INTO book_fts(rowid, title, author, description, isbn)
|
INSERT INTO book_fts(rowid, title, author, description, isbn)
|
||||||
@ -193,4 +222,104 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
END;
|
END;
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureBooksMetadataColumns(): void {
|
||||||
|
const columns = this.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>;
|
||||||
|
const names = new Set(columns.map((column) => column.name));
|
||||||
|
if (!names.has("isbn13")) {
|
||||||
|
this.sqlite.exec("ALTER TABLE books ADD COLUMN isbn13 TEXT");
|
||||||
|
}
|
||||||
|
if (!names.has("identifiers_json")) {
|
||||||
|
this.sqlite.exec("ALTER TABLE books ADD COLUMN identifiers_json TEXT");
|
||||||
|
}
|
||||||
|
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureMetadataSourceConfigColumns(): void {
|
||||||
|
const names = this.columnNames("metadata_source_config");
|
||||||
|
const now = sqlString(this.now());
|
||||||
|
if (!names.has("enabled")) {
|
||||||
|
this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1");
|
||||||
|
}
|
||||||
|
if (!names.has("priority")) {
|
||||||
|
this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN priority INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
if (!names.has("api_key")) {
|
||||||
|
this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN api_key TEXT");
|
||||||
|
}
|
||||||
|
if (!names.has("created_at")) {
|
||||||
|
this.sqlite.exec(`ALTER TABLE metadata_source_config ADD COLUMN created_at TEXT NOT NULL DEFAULT ${now}`);
|
||||||
|
}
|
||||||
|
if (!names.has("updated_at")) {
|
||||||
|
this.sqlite.exec(`ALTER TABLE metadata_source_config ADD COLUMN updated_at TEXT NOT NULL DEFAULT ${now}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureAutomationSettingsColumns(): void {
|
||||||
|
const names = this.columnNames("automation_settings");
|
||||||
|
const now = sqlString(this.now());
|
||||||
|
const disabledScan = sqlString(JSON.stringify({ frequency: "disabled", time: "03:00", dayOfWeek: 1 }));
|
||||||
|
const disabledEnrich = sqlString(JSON.stringify({ frequency: "disabled", time: "04:00", dayOfWeek: 1 }));
|
||||||
|
if (!names.has("watch_libraries")) {
|
||||||
|
this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN watch_libraries INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
if (!names.has("auto_enrich_new_books")) {
|
||||||
|
this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN auto_enrich_new_books INTEGER NOT NULL DEFAULT 1");
|
||||||
|
}
|
||||||
|
if (!names.has("isbn_priority_enabled")) {
|
||||||
|
this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN isbn_priority_enabled INTEGER NOT NULL DEFAULT 1");
|
||||||
|
}
|
||||||
|
if (!names.has("scan_schedule_json")) {
|
||||||
|
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN scan_schedule_json TEXT NOT NULL DEFAULT ${disabledScan}`);
|
||||||
|
}
|
||||||
|
if (!names.has("enrich_schedule_json")) {
|
||||||
|
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN enrich_schedule_json TEXT NOT NULL DEFAULT ${disabledEnrich}`);
|
||||||
|
}
|
||||||
|
if (!names.has("created_at")) {
|
||||||
|
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN created_at TEXT NOT NULL DEFAULT ${now}`);
|
||||||
|
}
|
||||||
|
if (!names.has("updated_at")) {
|
||||||
|
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN updated_at TEXT NOT NULL DEFAULT ${now}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private columnNames(table: string): Set<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);
|
||||||
|
|
||||||
|
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, "''")}'`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,8 @@ export const books = sqliteTable(
|
|||||||
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"),
|
||||||
language: text("language"),
|
language: text("language"),
|
||||||
publisher: text("publisher"),
|
publisher: text("publisher"),
|
||||||
publishedDate: text("published_date"),
|
publishedDate: text("published_date"),
|
||||||
@ -75,3 +77,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"] }).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,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";
|
||||||
|
}
|
||||||
68
apps/api/src/metadata/adapters/bnf.provider.ts
Normal file
68
apps/api/src/metadata/adapters/bnf.provider.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { XMLParser } from "fast-xml-parser";
|
||||||
|
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
|
||||||
|
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
|
||||||
|
|
||||||
|
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", removeNSPrefix: true });
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BnfProvider implements MetadataProvider {
|
||||||
|
readonly id = "bnf" as const;
|
||||||
|
|
||||||
|
async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise<MetadataMatch | null> {
|
||||||
|
const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10;
|
||||||
|
const query = isbn
|
||||||
|
? `bib.isbn all "${isbn}"`
|
||||||
|
: `bib.title all "${lookup.title.replace(/"/g, " ")}"`;
|
||||||
|
const url = new URL("https://catalogue.bnf.fr/api/SRU");
|
||||||
|
url.searchParams.set("version", "1.2");
|
||||||
|
url.searchParams.set("operation", "searchRetrieve");
|
||||||
|
url.searchParams.set("query", query);
|
||||||
|
url.searchParams.set("maximumRecords", "1");
|
||||||
|
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const parsed = parser.parse(await response.text());
|
||||||
|
const record = parsed?.searchRetrieveResponse?.records?.record?.recordData?.record;
|
||||||
|
if (!record) return null;
|
||||||
|
const fields = asArray(record.datafield);
|
||||||
|
return {
|
||||||
|
title: subfield(fields, "200", "a") ?? undefined,
|
||||||
|
author: subfield(fields, "200", "f") ?? ([subfield(fields, "700", "b"), subfield(fields, "700", "a")].filter(Boolean).join(" ") || null),
|
||||||
|
description: subfield(fields, "330", "a"),
|
||||||
|
isbn: bestIsbn(fields, lookup.identifiers.isbn13),
|
||||||
|
language: subfield(fields, "101", "a"),
|
||||||
|
publisher: subfield(fields, "210", "c") ?? subfield(fields, "214", "c"),
|
||||||
|
publishedDate: cleanDate(subfield(fields, "210", "d") ?? subfield(fields, "214", "d"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asArray(value: unknown): Array<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) => toIsbn13(candidate) === expectedIsbn13) ?? values.find((candidate) => toIsbn13(candidate)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanDate(value: string | null): string | null {
|
||||||
|
return value?.match(/\d{4}/)?.[0] ?? value;
|
||||||
|
}
|
||||||
53
apps/api/src/metadata/adapters/google-books.provider.ts
Normal file
53
apps/api/src/metadata/adapters/google-books.provider.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
|
||||||
|
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleBooksProvider implements MetadataProvider {
|
||||||
|
readonly id = "googlebooks" as const;
|
||||||
|
|
||||||
|
async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null> {
|
||||||
|
const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10;
|
||||||
|
const query = isbn
|
||||||
|
? `isbn:${isbn}`
|
||||||
|
: `intitle:${lookup.title}${lookup.author ? `+inauthor:${lookup.author}` : ""}`;
|
||||||
|
const url = new URL("https://www.googleapis.com/books/v1/volumes");
|
||||||
|
url.searchParams.set("q", query);
|
||||||
|
url.searchParams.set("maxResults", "1");
|
||||||
|
url.searchParams.set("printType", "books");
|
||||||
|
if (config.apiKey) url.searchParams.set("key", config.apiKey);
|
||||||
|
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(4000) });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = (await response.json()) as { items?: Array<{ volumeInfo?: Record<string, unknown> }> };
|
||||||
|
const info = data.items?.[0]?.volumeInfo;
|
||||||
|
if (!info) return null;
|
||||||
|
return {
|
||||||
|
title: stringValue(info.title) ?? undefined,
|
||||||
|
author: arrayJoin(info.authors),
|
||||||
|
description: stringValue(info.description),
|
||||||
|
language: stringValue(info.language),
|
||||||
|
publisher: stringValue(info.publisher),
|
||||||
|
publishedDate: stringValue(info.publishedDate),
|
||||||
|
isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, lookup.identifiers.isbn13)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayJoin(value: unknown): string | null {
|
||||||
|
return Array.isArray(value) && value.length ? value.map(String).join(", ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isbnFromIndustryIdentifiers(value: unknown, expectedIsbn13: string | null): string | null {
|
||||||
|
if (!Array.isArray(value)) return null;
|
||||||
|
const entries = value as Array<{ type?: unknown; identifier?: unknown }>;
|
||||||
|
const matching = entries.find((entry) => toIsbn13(stringValue(entry.identifier) ?? "") === expectedIsbn13)?.identifier;
|
||||||
|
if (matching) return stringValue(matching);
|
||||||
|
const isbn13 = entries.find((entry) => entry.type === "ISBN_13")?.identifier;
|
||||||
|
const isbn10 = entries.find((entry) => entry.type === "ISBN_10")?.identifier;
|
||||||
|
return stringValue(isbn13) ?? stringValue(isbn10);
|
||||||
|
}
|
||||||
16
apps/api/src/metadata/adapters/local.provider.ts
Normal file
16
apps/api/src/metadata/adapters/local.provider.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocalMetadataProvider implements MetadataProvider {
|
||||||
|
readonly id = "local" as const;
|
||||||
|
|
||||||
|
async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise<MetadataMatch> {
|
||||||
|
return {
|
||||||
|
title: lookup.title,
|
||||||
|
author: lookup.author,
|
||||||
|
isbn: lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10,
|
||||||
|
identifiers: lookup.identifiers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
100
apps/api/src/metadata/adapters/open-library.provider.ts
Normal file
100
apps/api/src/metadata/adapters/open-library.provider.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
|
||||||
|
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OpenLibraryProvider implements MetadataProvider {
|
||||||
|
readonly id = "openlibrary" as const;
|
||||||
|
|
||||||
|
async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise<MetadataMatch | null> {
|
||||||
|
const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10;
|
||||||
|
if (isbn) {
|
||||||
|
return this.lookupIsbn(isbn, lookup.identifiers.isbn13);
|
||||||
|
}
|
||||||
|
const query = `title:${lookup.title}${lookup.author ? ` author:${lookup.author}` : ""}`;
|
||||||
|
const url = new URL("https://openlibrary.org/search.json");
|
||||||
|
url.searchParams.set("q", query);
|
||||||
|
url.searchParams.set("limit", "1");
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
|
||||||
|
signal: AbortSignal.timeout(4000)
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = (await response.json()) as { docs?: Array<Record<string, unknown>> };
|
||||||
|
const doc = data.docs?.[0];
|
||||||
|
if (!doc) return null;
|
||||||
|
return {
|
||||||
|
title: stringValue(doc.title) ?? undefined,
|
||||||
|
author: arrayJoin(doc.author_name),
|
||||||
|
language: firstArrayValue(doc.language),
|
||||||
|
publisher: firstArrayValue(doc.publisher),
|
||||||
|
publishedDate: String(doc.first_publish_year ?? "") || null,
|
||||||
|
isbn: bestIsbn(doc.isbn, lookup.identifiers.isbn13)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lookupIsbn(isbn: string, expectedIsbn13: string | null): Promise<MetadataMatch | null> {
|
||||||
|
const response = await fetch(`https://openlibrary.org/isbn/${encodeURIComponent(isbn)}.json`, {
|
||||||
|
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
|
||||||
|
signal: AbortSignal.timeout(4000)
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const edition = (await response.json()) as Record<string, unknown>;
|
||||||
|
const author = await this.lookupAuthorName(edition.authors);
|
||||||
|
return {
|
||||||
|
title: stringValue(edition.title) ?? undefined,
|
||||||
|
author,
|
||||||
|
description: descriptionValue(edition.description),
|
||||||
|
isbn: bestIsbn([...(asStringArray(edition.isbn_13)), ...(asStringArray(edition.isbn_10))], expectedIsbn13),
|
||||||
|
language: languageValue(edition.languages),
|
||||||
|
publisher: firstArrayValue(edition.publishers),
|
||||||
|
publishedDate: stringValue(edition.publish_date)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lookupAuthorName(value: unknown): Promise<string | null> {
|
||||||
|
const key = (Array.isArray(value) ? value[0] : undefined)?.key;
|
||||||
|
if (typeof key !== "string") return null;
|
||||||
|
const response = await fetch(`https://openlibrary.org${key}.json`, {
|
||||||
|
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
|
||||||
|
signal: AbortSignal.timeout(3000)
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const author = (await response.json()) as Record<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) => toIsbn13(candidate) === expectedIsbn13) ?? values.find((candidate) => toIsbn13(candidate)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStringArray(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) ? value.map(String) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function descriptionValue(value: unknown): string | null {
|
||||||
|
if (typeof value === "string") return value.trim() || null;
|
||||||
|
if (typeof value === "object" && value && "value" in value) return stringValue(value.value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageValue(value: unknown): string | null {
|
||||||
|
const key = (Array.isArray(value) ? value[0] : undefined)?.key;
|
||||||
|
return typeof key === "string" ? key.split("/").pop() ?? null : null;
|
||||||
|
}
|
||||||
31
apps/api/src/metadata/extract-identifiers.test.ts
Normal file
31
apps/api/src/metadata/extract-identifiers.test.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ExtractIdentifiers, normalizeIsbn, toIsbn13 } from "./use-cases/extract-identifiers.js";
|
||||||
|
|
||||||
|
describe("ISBN normalization", () => {
|
||||||
|
it("accepts valid ISBN-10/13 and rejects bad checksums", () => {
|
||||||
|
expect(normalizeIsbn("0-306-40615-2")).toBe("0306406152");
|
||||||
|
expect(normalizeIsbn("978-0-306-40615-7")).toBe("9780306406157");
|
||||||
|
expect(normalizeIsbn("978-0-306-40615-8")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts ISBN-10 to ISBN-13", () => {
|
||||||
|
expect(toIsbn13("0-306-40615-2")).toBe("9780306406157");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ExtractIdentifiers", () => {
|
||||||
|
it("uses embedded PDF text before filename fallback candidates", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "readabook-isbn-"));
|
||||||
|
const file = join(dir, "fallback 9780306406157.pdf");
|
||||||
|
writeFileSync(file, "%PDF-1.4\n1 0 obj << /Title (Book) /Subject (ISBN 0-306-40615-2) >> endobj");
|
||||||
|
|
||||||
|
const identifiers = new ExtractIdentifiers().fromMetadataAndFile({ isbn: null }, file);
|
||||||
|
|
||||||
|
expect(identifiers.isbn10).toBe("0306406152");
|
||||||
|
expect(identifiers.isbn13).toBe("9780306406157");
|
||||||
|
expect(identifiers.candidates).toContain("9780306406157");
|
||||||
|
});
|
||||||
|
});
|
||||||
101
apps/api/src/metadata/metadata-providers.test.ts
Normal file
101
apps/api/src/metadata/metadata-providers.test.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { BnfProvider } from "./adapters/bnf.provider.js";
|
||||||
|
import { GoogleBooksProvider } from "./adapters/google-books.provider.js";
|
||||||
|
import { OpenLibraryProvider } from "./adapters/open-library.provider.js";
|
||||||
|
import { MetadataLookup, MetadataProviderConfig } from "./metadata.types.js";
|
||||||
|
|
||||||
|
const lookup: MetadataLookup = {
|
||||||
|
title: "Harry Potter et la Chambre des Secrets",
|
||||||
|
author: "J. K. Rowling",
|
||||||
|
filePath: "/library/HP/Harry Potter et la Chambre des Secrets (J.K. Rowling).epub",
|
||||||
|
identifiers: { isbn10: null, isbn13: "9782070612376", candidates: ["9782070612376"] }
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: MetadataProviderConfig = { provider: "openlibrary", enabled: true, priority: 1, apiKey: null };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("metadata providers", () => {
|
||||||
|
it("queries OpenLibrary by ISBN and normalizes the matching book", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
title: "Harry Potter et la Chambre des Secrets",
|
||||||
|
authors: [{ key: "/authors/OL23919A" }],
|
||||||
|
languages: [{ key: "/languages/fre" }],
|
||||||
|
publishers: ["Gallimard jeunesse"],
|
||||||
|
publish_date: "2007-03",
|
||||||
|
isbn_13: ["9782070612376"],
|
||||||
|
isbn_10: ["2070612379"]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ name: "J. K. Rowling" }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await new OpenLibraryProvider().lookup(lookup, config);
|
||||||
|
|
||||||
|
expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toBe("https://openlibrary.org/isbn/9782070612376.json");
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
title: "Harry Potter et la Chambre des Secrets",
|
||||||
|
author: "J. K. Rowling",
|
||||||
|
isbn: "9782070612376"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats Google Books quota exhaustion as a non-blocking miss", async () => {
|
||||||
|
const fetchMock = vi.fn(async () => jsonResponse({ error: { code: 429, status: "RESOURCE_EXHAUSTED" } }, 429));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await new GoogleBooksProvider().lookup(lookup, { ...config, provider: "googlebooks" });
|
||||||
|
|
||||||
|
expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toContain("q=isbn%3A9782070612376");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses BnF SRU UNIMARC records returned for ISBN lookup", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () =>
|
||||||
|
textResponse(`<?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"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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" } });
|
||||||
|
}
|
||||||
14
apps/api/src/metadata/metadata.module.ts
Normal file
14
apps/api/src/metadata/metadata.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { DatabaseModule } from "../database/database.module.js";
|
||||||
|
import { BnfProvider } from "./adapters/bnf.provider.js";
|
||||||
|
import { GoogleBooksProvider } from "./adapters/google-books.provider.js";
|
||||||
|
import { LocalMetadataProvider } from "./adapters/local.provider.js";
|
||||||
|
import { OpenLibraryProvider } from "./adapters/open-library.provider.js";
|
||||||
|
import { MetadataService } from "./metadata.service.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
providers: [MetadataService, LocalMetadataProvider, OpenLibraryProvider, GoogleBooksProvider, BnfProvider],
|
||||||
|
exports: [MetadataService]
|
||||||
|
})
|
||||||
|
export class MetadataModule {}
|
||||||
167
apps/api/src/metadata/metadata.service.ts
Normal file
167
apps/api/src/metadata/metadata.service.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
MetadataSourcesConfigDto,
|
||||||
|
UpdateMetadataSourcesConfigDto
|
||||||
|
} from "@readabook/shared";
|
||||||
|
import { DatabaseService } from "../database/database.service.js";
|
||||||
|
import { automationSettings, books, metadataSourceConfig } from "../database/schema.js";
|
||||||
|
import { BookMetadata } from "../scanner/metadata.js";
|
||||||
|
import { BnfProvider } from "./adapters/bnf.provider.js";
|
||||||
|
import { GoogleBooksProvider } from "./adapters/google-books.provider.js";
|
||||||
|
import { LocalMetadataProvider } from "./adapters/local.provider.js";
|
||||||
|
import { OpenLibraryProvider } from "./adapters/open-library.provider.js";
|
||||||
|
import { MetadataMatch, MetadataProvider, MetadataProviderConfig } from "./metadata.types.js";
|
||||||
|
import { ExtractIdentifiers, toIsbn13 } from "./use-cases/extract-identifiers.js";
|
||||||
|
import { ResolveProviderChain } from "./use-cases/resolve-provider-chain.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetadataService {
|
||||||
|
private readonly extractIdentifiers = new ExtractIdentifiers();
|
||||||
|
private readonly resolveProviderChain: ResolveProviderChain;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly database: DatabaseService,
|
||||||
|
local: LocalMetadataProvider,
|
||||||
|
openLibrary: OpenLibraryProvider,
|
||||||
|
googleBooks: GoogleBooksProvider,
|
||||||
|
bnf: BnfProvider
|
||||||
|
) {
|
||||||
|
this.resolveProviderChain = new ResolveProviderChain([local, openLibrary, googleBooks, bnf]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSourcesConfig(): MetadataSourcesConfigDto {
|
||||||
|
const settings = this.getAutomationRow();
|
||||||
|
return {
|
||||||
|
isbnPriorityEnabled: Boolean(settings.isbnPriorityEnabled),
|
||||||
|
sources: this.getProviderConfigs().map((source) => ({
|
||||||
|
provider: source.provider,
|
||||||
|
enabled: source.provider === "local" ? true : source.enabled,
|
||||||
|
priority: source.priority,
|
||||||
|
hasApiKey: Boolean(source.apiKey)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSourcesConfig(input: UpdateMetadataSourcesConfigDto): MetadataSourcesConfigDto {
|
||||||
|
const now = this.database.now();
|
||||||
|
if (input.isbnPriorityEnabled !== undefined) {
|
||||||
|
this.database.db
|
||||||
|
.update(automationSettings)
|
||||||
|
.set({ isbnPriorityEnabled: input.isbnPriorityEnabled, updatedAt: now })
|
||||||
|
.where(eq(automationSettings.id, 1))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
for (const source of input.sources ?? []) {
|
||||||
|
if ((source.provider as string) === "local") {
|
||||||
|
throw new BadRequestException("Local metadata source is always active and cannot be updated");
|
||||||
|
}
|
||||||
|
const values: Partial<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 }> {
|
||||||
|
const identifiers = this.extractIdentifiers.fromMetadataAndFile(localMetadata, filePath);
|
||||||
|
const configs = this.getProviderConfigs();
|
||||||
|
const chain = options.remote
|
||||||
|
? this.resolveProviderChain.resolve(configs)
|
||||||
|
: this.resolveProviderChain.resolve(configs).filter((entry) => entry.provider.id === "local");
|
||||||
|
let merged: BookMetadata = { ...localMetadata };
|
||||||
|
|
||||||
|
for (const { provider, config } of chain) {
|
||||||
|
try {
|
||||||
|
const match = await provider.lookup(
|
||||||
|
{
|
||||||
|
title: merged.title,
|
||||||
|
author: merged.author,
|
||||||
|
filePath,
|
||||||
|
identifiers
|
||||||
|
},
|
||||||
|
config
|
||||||
|
);
|
||||||
|
if (match) merged = mergeMetadata(merged, match);
|
||||||
|
} catch {
|
||||||
|
// Provider failures must not block local ingestion.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isbn13 = identifiers.isbn13 ?? (merged.isbn ? toIsbn13(merged.isbn) : null);
|
||||||
|
return {
|
||||||
|
...merged,
|
||||||
|
isbn: merged.isbn ?? isbn13 ?? identifiers.isbn10,
|
||||||
|
isbn13,
|
||||||
|
identifiersJson: JSON.stringify(identifiers)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async enrichBook(bookId: number): Promise<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 metadata: BookMetadata = {
|
||||||
|
title: book.title,
|
||||||
|
author: book.author,
|
||||||
|
description: book.description,
|
||||||
|
isbn: book.isbn,
|
||||||
|
language: book.language,
|
||||||
|
publisher: book.publisher,
|
||||||
|
publishedDate: book.publishedDate,
|
||||||
|
coverPath: book.coverPath
|
||||||
|
};
|
||||||
|
const enriched = await this.enrichMetadata(metadata, book.filePath, { remote: true });
|
||||||
|
return this.database.db
|
||||||
|
.update(books)
|
||||||
|
.set({
|
||||||
|
title: enriched.title,
|
||||||
|
author: enriched.author,
|
||||||
|
description: enriched.description,
|
||||||
|
isbn: enriched.isbn,
|
||||||
|
isbn13: enriched.isbn13,
|
||||||
|
identifiersJson: enriched.identifiersJson,
|
||||||
|
language: enriched.language,
|
||||||
|
publisher: enriched.publisher,
|
||||||
|
publishedDate: enriched.publishedDate,
|
||||||
|
coverPath: enriched.coverPath,
|
||||||
|
updatedAt: this.database.now()
|
||||||
|
})
|
||||||
|
.where(eq(books.id, book.id))
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getProviderConfigs(): MetadataProviderConfig[] {
|
||||||
|
return this.database.db
|
||||||
|
.select()
|
||||||
|
.from(metadataSourceConfig)
|
||||||
|
.all()
|
||||||
|
.map((row) => ({
|
||||||
|
provider: row.provider,
|
||||||
|
enabled: row.provider === "local" ? true : row.enabled,
|
||||||
|
priority: row.provider === "local" ? 0 : row.priority,
|
||||||
|
apiKey: row.apiKey
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAutomationRow(): typeof automationSettings.$inferSelect {
|
||||||
|
return this.database.db.select().from(automationSettings).where(eq(automationSettings.id, 1)).get()!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMetadata(current: BookMetadata, next: MetadataMatch): BookMetadata {
|
||||||
|
return {
|
||||||
|
title: next.title ?? current.title,
|
||||||
|
author: current.author ?? next.author ?? null,
|
||||||
|
description: current.description ?? next.description ?? null,
|
||||||
|
isbn: current.isbn ?? next.isbn ?? null,
|
||||||
|
language: current.language ?? next.language ?? null,
|
||||||
|
publisher: current.publisher ?? next.publisher ?? null,
|
||||||
|
publishedDate: current.publishedDate ?? next.publishedDate ?? null,
|
||||||
|
coverPath: current.coverPath ?? next.coverPath ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
32
apps/api/src/metadata/metadata.types.ts
Normal file
32
apps/api/src/metadata/metadata.types.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { BookMetadata } from "../scanner/metadata.js";
|
||||||
|
|
||||||
|
export type MetadataProviderId = "local" | "openlibrary" | "googlebooks" | "bnf";
|
||||||
|
|
||||||
|
export type BookIdentifiers = {
|
||||||
|
isbn10: string | null;
|
||||||
|
isbn13: string | null;
|
||||||
|
candidates: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MetadataLookup = {
|
||||||
|
title: string;
|
||||||
|
author: string | null;
|
||||||
|
filePath: string;
|
||||||
|
identifiers: BookIdentifiers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MetadataMatch = Partial<BookMetadata> & {
|
||||||
|
identifiers?: Partial<BookIdentifiers>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
20
apps/api/src/metadata/resolve-provider-chain.test.ts
Normal file
20
apps/api/src/metadata/resolve-provider-chain.test.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { MetadataProvider } from "./metadata.types.js";
|
||||||
|
import { ResolveProviderChain } from "./use-cases/resolve-provider-chain.js";
|
||||||
|
|
||||||
|
const provider = (id: MetadataProvider["id"]): MetadataProvider => ({
|
||||||
|
id,
|
||||||
|
lookup: async () => null
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ResolveProviderChain", () => {
|
||||||
|
it("keeps local active and orders enabled remote providers by priority", () => {
|
||||||
|
const chain = new ResolveProviderChain([provider("googlebooks"), provider("local"), provider("bnf")]).resolve([
|
||||||
|
{ provider: "local", enabled: false, priority: 99, apiKey: null },
|
||||||
|
{ provider: "googlebooks", enabled: true, priority: 2, apiKey: null },
|
||||||
|
{ provider: "bnf", enabled: true, priority: 1, apiKey: null }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(chain.map((entry) => entry.provider.id)).toEqual(["bnf", "googlebooks", "local"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
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 && 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@ 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 { listCbrImageEntries } from "../common/cbr.js";
|
||||||
import { listCbzImageEntries } from "../common/cbz.js";
|
import { listCbzImageEntries } from "../common/cbz.js";
|
||||||
|
|
||||||
export type BookMetadata = {
|
export type BookMetadata = {
|
||||||
@ -97,16 +97,10 @@ function extractCbzMetadata(filePath: string, storageDir: string): BookMetadata
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function extractCbrMetadata(filePath: string, storageDir: string): Promise<BookMetadata> {
|
async function extractCbrMetadata(filePath: string, storageDir: string): Promise<BookMetadata> {
|
||||||
const firstPage = (await listCbrImageEntries(filePath))[0];
|
await listCbrImageEntries(filePath);
|
||||||
const page = await readCbrPage(filePath, 1, storageDir);
|
|
||||||
const extension = extname(firstPage.entryName) || ".jpg";
|
|
||||||
const hash = createHash("sha256").update(filePath).digest("hex").slice(0, 24);
|
|
||||||
const target = join(storageDir, "covers", `${hash}${extension}`);
|
|
||||||
mkdirSync(dirname(target), { recursive: true });
|
|
||||||
writeFileSync(target, page.data);
|
|
||||||
return {
|
return {
|
||||||
...fallbackMetadata(filePath),
|
...fallbackMetadata(filePath),
|
||||||
coverPath: target
|
coverPath: 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 {}
|
||||||
|
|||||||
20
apps/api/src/scanner/scanner.service.test.ts
Normal file
20
apps/api/src/scanner/scanner.service.test.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { scanDigest } from "./scanner.service.js";
|
||||||
|
|
||||||
|
describe("scan digest", () => {
|
||||||
|
it("reports incomplete files without exposing huge traces", () => {
|
||||||
|
const detail = scanDigest(
|
||||||
|
19,
|
||||||
|
0,
|
||||||
|
[
|
||||||
|
{ filePath: "/library/broken.cbr", error: "x".repeat(300) },
|
||||||
|
{ filePath: "/library/broken.epub", error: "Invalid EPUB" }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(detail).toContain("Scanned 19 file(s)");
|
||||||
|
expect(detail).toContain("2 incomplete file(s)");
|
||||||
|
expect(detail).toContain("broken.cbr");
|
||||||
|
expect(detail.length).toBeLessThan(380);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,19 +1,19 @@
|
|||||||
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 } 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 { 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,36 +28,87 @@ 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)) {
|
||||||
|
seen.add(filePath);
|
||||||
|
try {
|
||||||
await this.ingestFile(library.id, filePath);
|
await this.ingestFile(library.id, filePath);
|
||||||
count += 1;
|
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;
|
||||||
|
for (const row of rows) {
|
||||||
|
await this.metadata.enrichBook(row.id);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
this.jobs.markSucceeded(jobId, `Enriched ${count} book(s)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = await extractMetadata(filePath, this.database.config.storageDir);
|
const localMetadata = await extractMetadata(filePath, this.database.config.storageDir);
|
||||||
if (this.database.config.openLibraryEnabled) {
|
|
||||||
try {
|
|
||||||
metadata = { ...metadata, ...(await this.openLibrary.enrich(metadata)) };
|
|
||||||
} catch {
|
|
||||||
// Remote enrichment is opportunistic; local ingestion must stay deterministic.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = this.database.now();
|
const now = this.database.now();
|
||||||
const format = bookFormatFromPath(filePath);
|
const format = bookFormatFromPath(filePath);
|
||||||
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get();
|
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get();
|
||||||
|
const shouldRemoteEnrich = !existing ? this.shouldAutoEnrichNewBooks() : true;
|
||||||
|
const metadata = await this.metadata.enrichMetadata(localMetadata, filePath, { remote: shouldRemoteEnrich });
|
||||||
const values = {
|
const values = {
|
||||||
libraryId,
|
libraryId,
|
||||||
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,
|
||||||
language: metadata.language,
|
language: metadata.language,
|
||||||
publisher: metadata.publisher,
|
publisher: metadata.publisher,
|
||||||
publishedDate: metadata.publishedDate,
|
publishedDate: metadata.publishedDate,
|
||||||
@ -73,6 +124,77 @@ export class ScannerService {
|
|||||||
? this.database.db.update(books).set(values).where(eq(books.id, existing.id)).returning().get()
|
? this.database.db.update(books).set(values).where(eq(books.id, existing.id)).returning().get()
|
||||||
: this.database.db.insert(books).values({ ...values, createdAt: now }).returning().get();
|
: this.database.db.insert(books).values({ ...values, createdAt: now }).returning().get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ingestIncompleteFile(libraryId: number, filePath: string): void {
|
||||||
|
const stats = statSync(filePath);
|
||||||
|
const now = this.database.now();
|
||||||
|
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get();
|
||||||
|
const values = {
|
||||||
|
libraryId,
|
||||||
|
title: basename(filePath, extname(filePath)),
|
||||||
|
author: null,
|
||||||
|
description: null,
|
||||||
|
isbn: null,
|
||||||
|
isbn13: null,
|
||||||
|
identifiersJson: JSON.stringify({ candidates: [], isbn10: null, isbn13: null }),
|
||||||
|
language: null,
|
||||||
|
publisher: null,
|
||||||
|
publishedDate: null,
|
||||||
|
format: bookFormatFromPath(filePath),
|
||||||
|
filePath,
|
||||||
|
coverPath: null,
|
||||||
|
fileSize: stats.size,
|
||||||
|
fileMtime: stats.mtime.toISOString(),
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
|
||||||
|
existing
|
||||||
|
? this.database.db.update(books).set(values).where(eq(books.id, existing.id)).returning().get()
|
||||||
|
: this.database.db.insert(books).values({ ...values, createdAt: now }).returning().get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeMissingBooks(libraryId: number, seen: Set<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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScanFailure = {
|
||||||
|
filePath: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function scanDigest(scanned: number, removed: number, failures: ScanFailure[], suffix?: string): string {
|
||||||
|
const base = suffix ? `Scanned ${scanned} file(s) ${suffix}` : `Scanned ${scanned} file(s), removed ${removed} missing book(s)`;
|
||||||
|
if (!failures.length) return base;
|
||||||
|
const examples = failures
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((failure) => `${basename(failure.filePath)}: ${truncate(failure.error)}`)
|
||||||
|
.join("; ");
|
||||||
|
const extra = failures.length > 3 ? `; ${failures.length - 3} more` : "";
|
||||||
|
return `${base}, ${failures.length} incomplete file(s): ${examples}${extra}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message) return truncate(error.message);
|
||||||
|
return truncate(String(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(value: string): string {
|
||||||
|
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function* walkBooks(root: string): Generator<string> {
|
function* walkBooks(root: string): Generator<string> {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { Session } from "./api/types";
|
|||||||
import { api } from "./api/client";
|
import { api } from "./api/client";
|
||||||
import { isPrivateRoute } from "./auth/routing";
|
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";
|
||||||
@ -31,6 +32,8 @@ 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 />
|
||||||
);
|
);
|
||||||
|
|||||||
@ -30,4 +30,78 @@ describe("api fallback helpers", () => {
|
|||||||
expect(init.method).toBe("DELETE");
|
expect(init.method).toBe("DELETE");
|
||||||
expect(headers.has("Content-Type")).toBe(false);
|
expect(headers.has("Content-Type")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("surfaces create library API errors without fallback", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ message: "Library path does not exist" }), {
|
||||||
|
status: 400,
|
||||||
|
statusText: "Bad Request",
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await expect(api.createLibrary({ name: "Books", path: "/missing", enabled: true })).rejects.toThrow("Library path does not exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fallback when scan enqueue fails", async () => {
|
||||||
|
const fetchMock = vi.fn().mockRejectedValue(new Error("offline"));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await expect(api.scanLibrary(42)).rejects.toThrow("offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends metadata source updates to the admin endpoint", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ isbnPriorityEnabled: false, sources: [] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await api.updateMetadataSources({
|
||||||
|
isbnPriorityEnabled: false,
|
||||||
|
sources: [{ provider: "openlibrary", enabled: true, priority: 1 }]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe("/admin/metadata-sources");
|
||||||
|
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(init.method).toBe("PUT");
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({
|
||||||
|
isbnPriorityEnabled: false,
|
||||||
|
sources: [{ provider: "openlibrary", enabled: true, priority: 1 }]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends automation settings to the admin endpoint", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
watchLibraries: true,
|
||||||
|
autoEnrichNewBooks: true,
|
||||||
|
scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 },
|
||||||
|
enrichSchedule: { frequency: "disabled", time: "04:00", dayOfWeek: 1 }
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await api.updateAutomationSettings({
|
||||||
|
watchLibraries: true,
|
||||||
|
scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe("/admin/automation");
|
||||||
|
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(init.method).toBe("PUT");
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({
|
||||||
|
watchLibraries: true,
|
||||||
|
scanSchedule: { frequency: "daily", time: "03:00", dayOfWeek: 1 }
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,17 +2,30 @@ import type {
|
|||||||
BookDto,
|
BookDto,
|
||||||
BookQueryDto,
|
BookQueryDto,
|
||||||
AuthStatusDto,
|
AuthStatusDto,
|
||||||
|
AutomationSettingsDto,
|
||||||
BootstrapAdminDto,
|
BootstrapAdminDto,
|
||||||
CreateLibraryDto,
|
CreateLibraryDto,
|
||||||
JobDto,
|
JobDto,
|
||||||
LibraryDto,
|
LibraryDto,
|
||||||
LoginDto,
|
LoginDto,
|
||||||
|
MetadataSourcesConfigDto,
|
||||||
ProgressDto,
|
ProgressDto,
|
||||||
|
UpdateAutomationSettingsDto,
|
||||||
UpdateAccountDto,
|
UpdateAccountDto,
|
||||||
|
UpdateMetadataSourcesConfigDto,
|
||||||
UpdateProgressDto,
|
UpdateProgressDto,
|
||||||
UserDto
|
UserDto
|
||||||
} from "@readabook/shared";
|
} from "@readabook/shared";
|
||||||
import { mockBooks, mockContinue, mockJobs, mockLibraries, mockProgress, mockUser } from "./mockData";
|
import {
|
||||||
|
mockAutomationSettings,
|
||||||
|
mockBooks,
|
||||||
|
mockContinue,
|
||||||
|
mockJobs,
|
||||||
|
mockLibraries,
|
||||||
|
mockMetadataSources,
|
||||||
|
mockProgress,
|
||||||
|
mockUser
|
||||||
|
} from "./mockData";
|
||||||
import type { CbzPagesDto, ContinueItem, Session } from "./types";
|
import type { CbzPagesDto, ContinueItem, Session } from "./types";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
@ -172,20 +185,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> {
|
async deleteLibrary(id: number): Promise<void> {
|
||||||
await request<{ ok: true }>(`/admin/libraries/${id}`, { method: "DELETE" });
|
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,4 +1,4 @@
|
|||||||
import type { BookDto, JobDto, LibraryDto, ProgressDto, UserDto } from "@readabook/shared";
|
import type { AutomationSettingsDto, BookDto, JobDto, LibraryDto, MetadataSourcesConfigDto, ProgressDto, UserDto } from "@readabook/shared";
|
||||||
import type { ContinueItem } from "./types";
|
import type { ContinueItem } from "./types";
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@ -24,6 +24,7 @@ export const mockBooks: BookDto[] = [
|
|||||||
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",
|
||||||
@ -42,6 +43,7 @@ export const mockBooks: BookDto[] = [
|
|||||||
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",
|
||||||
@ -60,6 +62,7 @@ export const mockBooks: BookDto[] = [
|
|||||||
author: "A. Muze",
|
author: "A. Muze",
|
||||||
description: "Un recit graphique indexe comme archive CBZ.",
|
description: "Un recit graphique indexe comme archive CBZ.",
|
||||||
isbn: null,
|
isbn: null,
|
||||||
|
isbn13: null,
|
||||||
language: "fr",
|
language: "fr",
|
||||||
publisher: "ReadaBook",
|
publisher: "ReadaBook",
|
||||||
publishedDate: "1934",
|
publishedDate: "1934",
|
||||||
@ -78,6 +81,7 @@ export const mockBooks: BookDto[] = [
|
|||||||
author: "L. Rar",
|
author: "L. Rar",
|
||||||
description: "Archive CBR lue avec le même parcours paginé que les comics CBZ.",
|
description: "Archive CBR lue avec le même parcours paginé que les comics CBZ.",
|
||||||
isbn: null,
|
isbn: null,
|
||||||
|
isbn13: null,
|
||||||
language: "fr",
|
language: "fr",
|
||||||
publisher: "ReadaBook",
|
publisher: "ReadaBook",
|
||||||
publishedDate: "1937",
|
publishedDate: "1937",
|
||||||
@ -106,3 +110,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 }
|
||||||
|
};
|
||||||
|
|||||||
@ -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,6 +7,7 @@ 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 }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
506
apps/web/src/pages/AdminAutomationPage.tsx
Normal file
506
apps/web/src/pages/AdminAutomationPage.tsx
Normal file
@ -0,0 +1,506 @@
|
|||||||
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ArrowDown, ArrowUp, Play, Save } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
AutomationFrequency,
|
||||||
|
AutomationScheduleDto,
|
||||||
|
AutomationSettingsDto,
|
||||||
|
MetadataProviderId,
|
||||||
|
MetadataSourcesConfigDto
|
||||||
|
} from "@readabook/shared";
|
||||||
|
import { api, getApiFallback } from "../api/client";
|
||||||
|
import { ErrorRibbon, LoadingState, Panel } from "../components/ui";
|
||||||
|
import {
|
||||||
|
metadataSourcesPayload,
|
||||||
|
moveSource,
|
||||||
|
normalizeMetadataSources,
|
||||||
|
providerLabels,
|
||||||
|
scheduleDays,
|
||||||
|
scheduleSummary
|
||||||
|
} from "./adminAutomation";
|
||||||
|
|
||||||
|
type AdminAutomationTab = "sources" | "automation";
|
||||||
|
type ApiState<T> = {
|
||||||
|
initial: T | null;
|
||||||
|
draft: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
error?: string;
|
||||||
|
success?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultMetadataConfig: MetadataSourcesConfigDto = {
|
||||||
|
isbnPriorityEnabled: true,
|
||||||
|
sources: [
|
||||||
|
{ provider: "local", enabled: true, priority: 0, hasApiKey: false },
|
||||||
|
{ provider: "openlibrary", enabled: false, priority: 1, hasApiKey: false },
|
||||||
|
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: false },
|
||||||
|
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultAutomationSettings: AutomationSettingsDto = {
|
||||||
|
watchLibraries: false,
|
||||||
|
autoEnrichNewBooks: false,
|
||||||
|
scanSchedule: { frequency: "disabled", time: "03:00", dayOfWeek: 1 },
|
||||||
|
enrichSchedule: { frequency: "disabled", time: "04:00", dayOfWeek: 1 }
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminAutomationPage() {
|
||||||
|
const [tab, setTab] = useState<AdminAutomationTab>("sources");
|
||||||
|
const [metadataState, setMetadataState] = useState<ApiState<MetadataSourcesConfigDto>>({
|
||||||
|
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<MetadataProviderId, 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>(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<MetadataSourcesConfigDto>;
|
||||||
|
dirty: boolean;
|
||||||
|
apiKeys: Partial<Record<MetadataProviderId, string>>;
|
||||||
|
setApiKeys: (next: Partial<Record<MetadataProviderId, string>>) => void;
|
||||||
|
onChange: (draft: MetadataSourcesConfigDto) => 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>
|
||||||
|
<label>
|
||||||
|
Cle API
|
||||||
|
<input
|
||||||
|
value={apiKeys[source.provider] ?? ""}
|
||||||
|
onChange={(event) => setApiKeys({ ...apiKeys, [source.provider]: event.target.value })}
|
||||||
|
placeholder={source.hasApiKey ? "cle conservee" : "optionnelle"}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ export function AdminPage() {
|
|||||||
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 [success, setSuccess] = useState<string>();
|
||||||
|
const [scanRetryLibrary, setScanRetryLibrary] = useState<LibraryDto>();
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -53,28 +54,38 @@ export function AdminPage() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setSuccess(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();
|
||||||
setSuccess(`Bibliothèque "${name}" ajoutée.`);
|
setName("Bibliotheque locale");
|
||||||
|
setPath("/library");
|
||||||
|
try {
|
||||||
|
await api.scanLibrary(created.id);
|
||||||
|
await refresh();
|
||||||
|
setSuccess(`Bibliothèque "${created.name}" ajoutée. Scan initial demandé.`);
|
||||||
|
} catch (scanError) {
|
||||||
|
setScanRetryLibrary(created);
|
||||||
|
setSuccess(
|
||||||
|
`Bibliothèque "${created.name}" ajoutée, mais le scan initial n'a pas pu être demandé. Tu peux réessayer le scan.`
|
||||||
|
);
|
||||||
|
setError(scanError instanceof Error ? `Scan initial impossible : ${scanError.message}` : "Scan initial impossible.");
|
||||||
|
}
|
||||||
} catch (createError) {
|
} catch (createError) {
|
||||||
const fallback = getApiFallback<LibraryDto>(createError);
|
setError(createError instanceof Error ? `Création impossible : ${createError.message}` : "Création impossible.");
|
||||||
if (fallback) setLibraries((current) => [fallback, ...current]);
|
|
||||||
setError(fallback ? "Creation en mode secours, synchronisation a retenter." : "Creation impossible");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scan(id: number) {
|
async function scan(id: number) {
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setSuccess(undefined);
|
setSuccess(undefined);
|
||||||
|
setScanRetryLibrary(undefined);
|
||||||
try {
|
try {
|
||||||
await api.scanLibrary(id);
|
await api.scanLibrary(id);
|
||||||
await refresh();
|
await refresh();
|
||||||
setSuccess("Scan demandé.");
|
setSuccess("Scan demandé.");
|
||||||
} catch (scanError) {
|
} catch (scanError) {
|
||||||
const fallback = getApiFallback<JobDto>(scanError);
|
setError(scanError instanceof Error ? `Scan impossible : ${scanError.message}` : "Scan impossible.");
|
||||||
if (fallback) setJobs((current) => [fallback, ...current]);
|
|
||||||
setError(fallback ? "Scan place en file de secours, statut a verifier." : "Scan impossible");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,6 +97,7 @@ export function AdminPage() {
|
|||||||
|
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setSuccess(undefined);
|
setSuccess(undefined);
|
||||||
|
setScanRetryLibrary(undefined);
|
||||||
try {
|
try {
|
||||||
await api.deleteLibrary(library.id);
|
await api.deleteLibrary(library.id);
|
||||||
setLibraries((current) => current.filter((item) => item.id !== library.id));
|
setLibraries((current) => current.filter((item) => item.id !== library.id));
|
||||||
@ -104,14 +116,22 @@ export function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
<ErrorRibbon message={error} />
|
<ErrorRibbon message={error} />
|
||||||
{success && <div className="success-ribbon">{success}</div>}
|
{success && <div className="success-ribbon">{success}</div>}
|
||||||
{error && (
|
{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">
|
<div className="retry-row">
|
||||||
<span>Les formulaires restent disponibles.</span>
|
<span>Les formulaires restent disponibles.</span>
|
||||||
<button className="ghost-button" onClick={() => void refresh()}>
|
<button className="ghost-button" onClick={() => void refresh()}>
|
||||||
Reessayer
|
Reessayer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
<form className="admin-form" onSubmit={createLibrary}>
|
<form className="admin-form" onSubmit={createLibrary}>
|
||||||
<label>
|
<label>
|
||||||
Nom de la bibliothèque
|
Nom de la bibliothèque
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
}, [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) => {
|
||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
@ -61,7 +62,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
return (
|
return (
|
||||||
<div className="reader-page">
|
<div className="reader-page">
|
||||||
<header className="reader-topbar">
|
<header className="reader-topbar">
|
||||||
<button className="ghost-button" onClick={() => navigate(book ? `/book/${book.id}` : "/home")}>
|
<button className="ghost-button" onClick={() => navigate(backHref)}>
|
||||||
<ArrowLeft size={17} />
|
<ArrowLeft size={17} />
|
||||||
Fiche
|
Fiche
|
||||||
</button>
|
</button>
|
||||||
@ -87,11 +88,11 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : book.format === "pdf" ? (
|
) : book.format === "pdf" ? (
|
||||||
<PdfReader url={fileUrl} page={page} onPageCommit={savePdfPage} />
|
<PdfReader url={fileUrl} page={page} backHref={backHref} onPageCommit={savePdfPage} />
|
||||||
) : book.format === "cbz" || book.format === "cbr" ? (
|
) : book.format === "cbz" || book.format === "cbr" ? (
|
||||||
<CbzReader bookId={book.id} page={page} onPageCommit={saveComicPage} />
|
<CbzReader bookId={book.id} page={page} onPageCommit={saveComicPage} />
|
||||||
) : (
|
) : (
|
||||||
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} />
|
<EpubReader url={fileUrl} locator={progress?.locator} backHref={backHref} onLocatorChange={saveEpubLocator} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
43
apps/web/src/pages/adminAutomation.test.ts
Normal file
43
apps/web/src/pages/adminAutomation.test.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { MetadataSourcesConfigDto } from "@readabook/shared";
|
||||||
|
import { metadataSourcesPayload, moveSource, normalizeMetadataSources, scheduleSummary } from "./adminAutomation";
|
||||||
|
|
||||||
|
const config: MetadataSourcesConfigDto = {
|
||||||
|
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 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
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 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves only external providers", () => {
|
||||||
|
const moved = moveSource(normalizeMetadataSources(config).sources, "bnf", -1);
|
||||||
|
expect(moved.map((source) => source.provider)).toEqual(["local", "openlibrary", "bnf", "googlebooks"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes weekly schedules", () => {
|
||||||
|
expect(scheduleSummary({ frequency: "weekly", time: "04:30", dayOfWeek: 1 }, "Scan")).toBe("Scan chaque lundi a 04:30.");
|
||||||
|
});
|
||||||
|
});
|
||||||
65
apps/web/src/pages/adminAutomation.ts
Normal file
65
apps/web/src/pages/adminAutomation.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import type {
|
||||||
|
AutomationScheduleDto,
|
||||||
|
MetadataProviderId,
|
||||||
|
MetadataSourceConfigDto,
|
||||||
|
MetadataSourcesConfigDto,
|
||||||
|
UpdateMetadataSourcesConfigDto
|
||||||
|
} from "@readabook/shared";
|
||||||
|
|
||||||
|
export const providerLabels: Record<MetadataProviderId, string> = {
|
||||||
|
local: "Fichier local",
|
||||||
|
openlibrary: "OpenLibrary",
|
||||||
|
googlebooks: "Google Books",
|
||||||
|
bnf: "BnF"
|
||||||
|
};
|
||||||
|
|
||||||
|
const weekdays = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"];
|
||||||
|
|
||||||
|
export function normalizeMetadataSources(config: MetadataSourcesConfigDto): MetadataSourcesConfigDto {
|
||||||
|
const sorted = [...config.sources].sort((left, right) => left.priority - right.priority);
|
||||||
|
const local = sorted.find((source) => source.provider === "local") ?? { provider: "local", enabled: true, priority: 0, hasApiKey: false };
|
||||||
|
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: MetadataSourcesConfigDto): UpdateMetadataSourcesConfigDto {
|
||||||
|
const sources: NonNullable<UpdateMetadataSourcesConfigDto["sources"]> = [];
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveSource(sources: MetadataSourceConfigDto[], provider: MetadataProviderId, direction: -1 | 1): MetadataSourceConfigDto[] {
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }));
|
||||||
@ -1,43 +1,145 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
|
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
||||||
|
|
||||||
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,
|
||||||
|
onLocatorChange
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
locator?: string;
|
||||||
|
backHref: string;
|
||||||
|
onLocatorChange: (locator: string, percent: number) => void;
|
||||||
|
}) {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const [frameKey, setFrameKey] = useState(0);
|
const viewRef = useRef<FoliateView | null>(null);
|
||||||
const [status, setStatus] = useState("Ouverture EPUB");
|
const locatorRef = useRef(locator);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
|
||||||
|
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" });
|
||||||
} catch {
|
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
||||||
setStatus("Apercu EPUB indisponible dans ce navigateur");
|
const blob = await response.blob();
|
||||||
|
if (cancelled || !hostRef.current) return;
|
||||||
|
|
||||||
|
view = document.createElement("foliate-view") as FoliateView;
|
||||||
|
view.classList.add("epub-view");
|
||||||
|
view.addEventListener("relocate", (event) => {
|
||||||
|
const location = (event as CustomEvent<FoliateLocation>).detail;
|
||||||
|
if (location?.cfi) onLocatorChange(location.cfi, locationPercent(location));
|
||||||
|
});
|
||||||
|
hostRef.current.replaceChildren(view);
|
||||||
|
viewRef.current = view;
|
||||||
|
|
||||||
|
const file = new File([blob], epubFileName(url), { type: blob.type || "application/epub+zip" });
|
||||||
|
await view.open(file);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (locatorRef.current) await view.goTo(locatorRef.current);
|
||||||
|
else await view.next();
|
||||||
|
setLoading(false);
|
||||||
|
} catch (mountError) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(readerErrorMessage(mountError, "EPUB indisponible"));
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mount();
|
}
|
||||||
|
|
||||||
|
void mount();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
view?.close?.();
|
||||||
|
view?.remove();
|
||||||
|
if (viewRef.current === view) viewRef.current = null;
|
||||||
};
|
};
|
||||||
}, [url]);
|
}, [attempt, onLocatorChange, url]);
|
||||||
|
|
||||||
|
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">
|
||||||
<iframe key={frameKey} title="EPUB" src={url} />
|
{loading && (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
<span>{status}</span>
|
<span>Ouverture EPUB</span>
|
||||||
<button className="ghost-button" onClick={() => onLocatorChange(locator ?? "epub:start", locator ? 35 : 1)}>
|
</div>
|
||||||
Marquer la position
|
)}
|
||||||
|
<div className="epub-host" ref={hostRef} />
|
||||||
|
<div className="reader-stepper">
|
||||||
|
<button className="ghost-button" onClick={() => void viewRef.current?.goLeft()} disabled={loading}>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Précédent
|
||||||
</button>
|
</button>
|
||||||
<button className="ghost-button" onClick={() => setFrameKey((value) => value + 1)}>
|
<span>{loading ? "Chargement" : "Lecture intégrée"}</span>
|
||||||
Recharger
|
<button className="ghost-button" onClick={() => void viewRef.current?.goRight()} disabled={loading}>
|
||||||
|
Suivant
|
||||||
|
<ArrowRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,21 +1,36 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { 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, readerErrorMessage } from "./ReaderError";
|
||||||
|
import { pdfWorkerSrc } from "./pdfWorker";
|
||||||
|
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
|
||||||
|
|
||||||
export function PdfReader({ url, page, onPageCommit }: { url: string; page: number; onPageCommit: (page: number, pages: number) => void }) {
|
export function PdfReader({
|
||||||
|
url,
|
||||||
|
page,
|
||||||
|
backHref,
|
||||||
|
onPageCommit
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
page: number;
|
||||||
|
backHref: string;
|
||||||
|
onPageCommit: (page: number, pages: number) => void;
|
||||||
|
}) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [pages, setPages] = useState(1);
|
const [pages, setPages] = useState(1);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
|
||||||
|
let renderTask: pdfjs.RenderTask | undefined;
|
||||||
async function render() {
|
async function render() {
|
||||||
try {
|
try {
|
||||||
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
const loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
||||||
const document = await loadingTask.promise;
|
const document = await loadingTask.promise;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPages(document.numPages);
|
setPages(document.numPages);
|
||||||
@ -27,37 +42,53 @@ export function PdfReader({ url, page, onPageCommit }: { url: string; page: numb
|
|||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
if (!context) return;
|
if (!context) return;
|
||||||
await pdfPage.render({ canvas, canvasContext: context, viewport }).promise;
|
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
|
||||||
|
await renderTask.promise;
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
} catch (renderError) {
|
} catch (renderError) {
|
||||||
setError(renderError instanceof Error ? renderError.message : "PDF indisponible");
|
if (!cancelled) {
|
||||||
|
setError(readerErrorMessage(renderError, "PDF indisponible"));
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
render();
|
}
|
||||||
|
void render();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
renderTask?.cancel();
|
||||||
|
void loadingTask?.destroy();
|
||||||
};
|
};
|
||||||
}, [url, page, attempt]);
|
}, [url, page, attempt]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pdf-reader">
|
<div className="pdf-reader">
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="reader-fallback">
|
<ReaderError
|
||||||
<span>{error}</span>
|
title="Lecture PDF indisponible"
|
||||||
<button className="ghost-button" onClick={() => setAttempt((value) => value + 1)}>
|
detail="ReadaBook n'a pas pu ouvrir ce fichier dans le lecteur web."
|
||||||
Reessayer
|
technicalDetail={error}
|
||||||
</button>
|
downloadUrl={url}
|
||||||
</div>
|
backHref={backHref}
|
||||||
|
onRetry={() => setAttempt((value) => value + 1)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{loading && (
|
||||||
|
<div className="reader-fallback">
|
||||||
|
<span>Ouverture PDF</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<canvas ref={canvasRef} />
|
<canvas ref={canvasRef} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="reader-stepper">
|
<div className="reader-stepper">
|
||||||
<button className="ghost-button" onClick={() => onPageCommit(Math.max(1, page - 1), pages)}>
|
<button className="ghost-button" onClick={() => onPageCommit(Math.max(1, page - 1), pages)} disabled={Boolean(error) || loading}>
|
||||||
Precedent
|
Précédent
|
||||||
</button>
|
</button>
|
||||||
<span>
|
<span>
|
||||||
{page} / {pages}
|
{page} / {pages}
|
||||||
</span>
|
</span>
|
||||||
<button className="ghost-button" onClick={() => onPageCommit(Math.min(pages, page + 1), pages)}>
|
<button className="ghost-button" onClick={() => onPageCommit(Math.min(pages, page + 1), pages)} disabled={Boolean(error) || loading}>
|
||||||
Suivant
|
Suivant
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
53
apps/web/src/reader/ReaderError.tsx
Normal file
53
apps/web/src/reader/ReaderError.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { ArrowLeft, Download, RotateCcw } from "lucide-react";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
|
||||||
|
export function readerErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof Error && error.message.trim()) return error.message;
|
||||||
|
if (typeof error === "string" && error.trim()) return error;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReaderError({
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
technicalDetail,
|
||||||
|
downloadUrl,
|
||||||
|
backHref,
|
||||||
|
onRetry
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
technicalDetail?: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
backHref: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="reader-error">
|
||||||
|
<div>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{detail}</p>
|
||||||
|
</div>
|
||||||
|
<div className="reader-error-actions">
|
||||||
|
<button className="ghost-button" onClick={onRetry}>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" onClick={() => navigate(backHref)}>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Retour à la fiche
|
||||||
|
</button>
|
||||||
|
<a className="ghost-button" href={downloadUrl} download>
|
||||||
|
<Download size={16} />
|
||||||
|
Télécharger
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{technicalDetail && (
|
||||||
|
<details>
|
||||||
|
<summary>Détail technique</summary>
|
||||||
|
<pre>{technicalDetail}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/web/src/reader/pdfWorker.ts
Normal file
1
apps/web/src/reader/pdfWorker.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const pdfWorkerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
|
||||||
20
apps/web/src/reader/readerRuntime.test.ts
Normal file
20
apps/web/src/reader/readerRuntime.test.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { epubFileName } from "./EpubReader";
|
||||||
|
import { pdfWorkerSrc } from "./pdfWorker";
|
||||||
|
import { readerErrorMessage } from "./ReaderError";
|
||||||
|
|
||||||
|
describe("reader runtime helpers", () => {
|
||||||
|
it("extracts an EPUB file name from the file URL", () => {
|
||||||
|
expect(epubFileName("http://readabook.local/books/12/file?token=abc")).toBe("book.epub");
|
||||||
|
expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps PDF.js worker source on the bundled module worker", () => {
|
||||||
|
expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes reader technical errors", () => {
|
||||||
|
expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed");
|
||||||
|
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
.brand-button,
|
.brand-button,
|
||||||
.side-rail nav button,
|
.side-rail nav button,
|
||||||
|
.admin-tabs button,
|
||||||
.ghost-button,
|
.ghost-button,
|
||||||
.primary-button {
|
.primary-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@ -138,7 +139,8 @@ h2 {
|
|||||||
|
|
||||||
.ghost-button:hover,
|
.ghost-button:hover,
|
||||||
.side-rail nav button:hover,
|
.side-rail nav button:hover,
|
||||||
.brand-button:hover {
|
.brand-button:hover,
|
||||||
|
.admin-tabs button:hover {
|
||||||
border-color: rgba(213, 168, 77, 0.55);
|
border-color: rgba(213, 168, 77, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -380,6 +382,15 @@ input {
|
|||||||
background: rgba(0, 0, 0, 0.22);
|
background: rgba(0, 0, 0, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
min-height: 42px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0 34px 0 12px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
.full-width {
|
.full-width {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
@ -442,6 +453,138 @@ input {
|
|||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs button {
|
||||||
|
min-width: 170px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs button.active {
|
||||||
|
border-color: rgba(213, 168, 77, 0.72);
|
||||||
|
background: rgba(213, 168, 77, 0.16);
|
||||||
|
color: var(--brass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.automation-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-heading h2 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-stack,
|
||||||
|
.provider-list,
|
||||||
|
.schedule-controls {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
min-height: 20px;
|
||||||
|
margin-top: 2px;
|
||||||
|
accent-color: var(--brass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row span,
|
||||||
|
.provider-row > div:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row strong,
|
||||||
|
.provider-row strong {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(210px, 1fr) minmax(190px, 280px) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-local {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-row span,
|
||||||
|
.provider-row small {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-actions,
|
||||||
|
.save-bar,
|
||||||
|
.save-bar div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-actions {
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 42px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-text-button {
|
||||||
|
padding: 0 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.active {
|
||||||
|
border-color: rgba(45, 111, 99, 0.72);
|
||||||
|
color: #d8fff5;
|
||||||
|
background: rgba(45, 111, 99, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-controls {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-bar {
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state,
|
.empty-state,
|
||||||
.loading-state {
|
.loading-state {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -533,8 +676,23 @@ input {
|
|||||||
min-height: calc(100vh - 120px);
|
min-height: calc(100vh - 120px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.epub-host {
|
||||||
|
display: grid;
|
||||||
|
width: min(100%, 980px);
|
||||||
|
height: calc(100vh - 170px);
|
||||||
|
min-height: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.epub-view {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #f7f0df;
|
||||||
|
color: #17110d;
|
||||||
|
}
|
||||||
|
|
||||||
.pdf-reader canvas,
|
.pdf-reader canvas,
|
||||||
.epub-reader iframe,
|
|
||||||
.cbz-reader img {
|
.cbz-reader img {
|
||||||
max-width: min(100%, 980px);
|
max-width: min(100%, 980px);
|
||||||
max-height: calc(100vh - 170px);
|
max-height: calc(100vh - 170px);
|
||||||
@ -548,11 +706,6 @@ input {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.epub-reader iframe {
|
|
||||||
width: min(100%, 980px);
|
|
||||||
height: calc(100vh - 170px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-fallback {
|
.reader-fallback {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -569,6 +722,46 @@ input {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-error {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
width: min(100%, 620px);
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid rgba(169, 72, 52, 0.72);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(169, 72, 52, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-error p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-error-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-error a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-error details {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-error summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-error pre {
|
||||||
|
overflow: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
@ -592,7 +785,7 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.side-rail nav {
|
.side-rail nav {
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -638,7 +831,27 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.library-table > div,
|
.library-table > div,
|
||||||
.search-form {
|
.search-form,
|
||||||
|
.automation-grid,
|
||||||
|
.provider-row,
|
||||||
|
.provider-local,
|
||||||
|
.schedule-controls,
|
||||||
|
.save-bar,
|
||||||
|
.reader-error-actions {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-bar,
|
||||||
|
.save-bar div,
|
||||||
|
.provider-actions,
|
||||||
|
.reader-error-actions {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-bar div,
|
||||||
|
.provider-actions,
|
||||||
|
.reader-error-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,10 +33,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input {
|
input,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.52;
|
||||||
|
}
|
||||||
|
|||||||
5
apps/web/src/vite-env.d.ts
vendored
5
apps/web/src/vite-env.d.ts
vendored
@ -5,3 +5,8 @@ declare module "foliate-js/epub.js" {
|
|||||||
export default module;
|
export default module;
|
||||||
export const EPUB: unknown;
|
export const EPUB: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "foliate-js/view.js" {
|
||||||
|
const module: unknown;
|
||||||
|
export default module;
|
||||||
|
}
|
||||||
|
|||||||
@ -11,11 +11,12 @@ services:
|
|||||||
STORAGE_DIR: /data/storage
|
STORAGE_DIR: /data/storage
|
||||||
JWT_SECRET: ${JWT_SECRET:-dev-change-me-readabook}
|
JWT_SECRET: ${JWT_SECRET:-dev-change-me-readabook}
|
||||||
OPEN_LIBRARY_ENABLED: ${OPEN_LIBRARY_ENABLED:-true}
|
OPEN_LIBRARY_ENABLED: ${OPEN_LIBRARY_ENABLED:-true}
|
||||||
|
LIBRARY_PATH_ALIASES: ${READABOOK_LIBRARY_ALIAS_FROM:-/library}=/library
|
||||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@readabook.local}
|
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@readabook.local}
|
||||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-readabook-admin-change-me}
|
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-readabook-admin-change-me}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./data/library:/library:ro
|
- /home/anthony/Documents/Projects/ReadaBook/Books:/library:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@ -91,6 +91,7 @@ export const BookSchema = z.object({
|
|||||||
author: z.string().nullable(),
|
author: z.string().nullable(),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
isbn: z.string().nullable(),
|
isbn: z.string().nullable(),
|
||||||
|
isbn13: z.string().nullable(),
|
||||||
language: z.string().nullable(),
|
language: z.string().nullable(),
|
||||||
publisher: z.string().nullable(),
|
publisher: z.string().nullable(),
|
||||||
publishedDate: z.string().nullable(),
|
publishedDate: z.string().nullable(),
|
||||||
@ -137,3 +138,55 @@ export const JobSchema = z.object({
|
|||||||
updatedAt: z.string()
|
updatedAt: z.string()
|
||||||
});
|
});
|
||||||
export type JobDto = z.infer<typeof JobSchema>;
|
export type JobDto = z.infer<typeof JobSchema>;
|
||||||
|
|
||||||
|
export const MetadataProviderIdSchema = z.enum(["local", "openlibrary", "googlebooks", "bnf"]);
|
||||||
|
export type MetadataProviderId = z.infer<typeof MetadataProviderIdSchema>;
|
||||||
|
|
||||||
|
export const MetadataSourceConfigSchema = z.object({
|
||||||
|
provider: MetadataProviderIdSchema,
|
||||||
|
enabled: z.boolean(),
|
||||||
|
priority: z.number().int().min(0),
|
||||||
|
hasApiKey: z.boolean().default(false)
|
||||||
|
});
|
||||||
|
export type MetadataSourceConfigDto = z.infer<typeof MetadataSourceConfigSchema>;
|
||||||
|
|
||||||
|
export const MetadataSourcesConfigSchema = z.object({
|
||||||
|
isbnPriorityEnabled: z.boolean(),
|
||||||
|
sources: z.array(MetadataSourceConfigSchema)
|
||||||
|
});
|
||||||
|
export type MetadataSourcesConfigDto = z.infer<typeof MetadataSourcesConfigSchema>;
|
||||||
|
|
||||||
|
export const UpdateMetadataSourceConfigSchema = z.object({
|
||||||
|
provider: MetadataProviderIdSchema.exclude(["local"]),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
priority: z.number().int().min(1).max(100),
|
||||||
|
apiKey: z.string().min(1).nullable().optional()
|
||||||
|
});
|
||||||
|
export type UpdateMetadataSourceConfigDto = z.infer<typeof UpdateMetadataSourceConfigSchema>;
|
||||||
|
|
||||||
|
export const UpdateMetadataSourcesConfigSchema = z.object({
|
||||||
|
isbnPriorityEnabled: z.boolean().optional(),
|
||||||
|
sources: z.array(UpdateMetadataSourceConfigSchema).optional()
|
||||||
|
});
|
||||||
|
export type UpdateMetadataSourcesConfigDto = z.infer<typeof UpdateMetadataSourcesConfigSchema>;
|
||||||
|
|
||||||
|
export const AutomationFrequencySchema = z.enum(["disabled", "daily", "weekly"]);
|
||||||
|
export type AutomationFrequency = z.infer<typeof AutomationFrequencySchema>;
|
||||||
|
|
||||||
|
export const AutomationScheduleSchema = z.object({
|
||||||
|
frequency: AutomationFrequencySchema,
|
||||||
|
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).default("03:00"),
|
||||||
|
dayOfWeek: z.number().int().min(0).max(6).default(1)
|
||||||
|
});
|
||||||
|
export type AutomationScheduleDto = z.infer<typeof AutomationScheduleSchema>;
|
||||||
|
|
||||||
|
export const AutomationSettingsSchema = z.object({
|
||||||
|
watchLibraries: z.boolean(),
|
||||||
|
autoEnrichNewBooks: z.boolean(),
|
||||||
|
scanSchedule: AutomationScheduleSchema,
|
||||||
|
enrichSchedule: AutomationScheduleSchema
|
||||||
|
});
|
||||||
|
export type AutomationSettingsDto = z.infer<typeof AutomationSettingsSchema>;
|
||||||
|
|
||||||
|
export const UpdateAutomationSettingsSchema = AutomationSettingsSchema.partial();
|
||||||
|
export type UpdateAutomationSettingsDto = z.infer<typeof UpdateAutomationSettingsSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user