feat(api,shared): métadonnées multi-providers et automatisation des scans/enrichissements

Chaîne de résolution metadata (local, Open Library, Google Books, BNF)
avec activation/priorité/clé API par provider, colonnes isbn13 et
identifiers sur les livres, et intégration au scanner pour compléter
métadonnées et jaquettes manquantes. Module automation: réglages
persistés, planifications scan/enrichissement et déclenchement manuel,
exposés via des endpoints admin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-23 13:10:45 +02:00
parent 1ac4144d0e
commit 48e9459cf3
23 changed files with 1029 additions and 24 deletions

View File

@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module.js";
import { ScannerModule } from "../scanner/scanner.module.js";
import { AutomationService } from "./automation.service.js";
@Module({
imports: [DatabaseModule, ScannerModule],
providers: [AutomationService],
exports: [AutomationService]
})
export class AutomationModule {}

View File

@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { nextRunAt } from "./automation.service.js";
describe("automation scheduling", () => {
it("computes the next daily run", () => {
const next = nextRunAt({ frequency: "daily", time: "03:30", dayOfWeek: 1 }, new Date("2026-08-23T02:00:00.000Z"));
expect(next.toISOString()).toBe("2026-08-23T03:30:00.000Z");
});
it("moves elapsed weekly runs to the next week", () => {
const next = nextRunAt({ frequency: "weekly", time: "03:30", dayOfWeek: 0 }, new Date("2026-08-23T04:00:00.000Z"));
expect(next.toISOString()).toBe("2026-08-30T03:30:00.000Z");
});
});

View File

@ -0,0 +1,209 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { Dirent, FSWatcher, readdirSync, watch } from "node:fs";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { AutomationScheduleDto, AutomationSettingsDto, UpdateAutomationSettingsDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { automationSettings, libraries } from "../database/schema.js";
import { ScannerService } from "../scanner/scanner.service.js";
type WatchEntry = {
watchers: FSWatcher[];
timer: NodeJS.Timeout | null;
};
const defaultSchedule: AutomationScheduleDto = { frequency: "disabled", time: "03:00", dayOfWeek: 1 };
@Injectable()
export class AutomationService implements OnModuleInit, OnModuleDestroy {
private readonly watchers = new Map<number, WatchEntry>();
private scanTimer: NodeJS.Timeout | null = null;
private enrichTimer: NodeJS.Timeout | null = null;
constructor(
private readonly database: DatabaseService,
private readonly scanner: ScannerService
) {}
onModuleInit(): void {
this.applyRuntimeSettings();
}
onModuleDestroy(): void {
this.stopWatchers();
this.clearSchedules();
}
getSettings(): AutomationSettingsDto {
const row = this.readRow();
return {
watchLibraries: row.watchLibraries,
autoEnrichNewBooks: row.autoEnrichNewBooks,
scanSchedule: parseSchedule(row.scanScheduleJson),
enrichSchedule: parseSchedule(row.enrichScheduleJson)
};
}
updateSettings(input: UpdateAutomationSettingsDto): AutomationSettingsDto {
const current = this.getSettings();
const next: AutomationSettingsDto = {
watchLibraries: input.watchLibraries ?? current.watchLibraries,
autoEnrichNewBooks: input.autoEnrichNewBooks ?? current.autoEnrichNewBooks,
scanSchedule: input.scanSchedule ? normalizeSchedule(input.scanSchedule) : current.scanSchedule,
enrichSchedule: input.enrichSchedule ? normalizeSchedule(input.enrichSchedule) : current.enrichSchedule
};
this.database.db
.update(automationSettings)
.set({
watchLibraries: next.watchLibraries,
autoEnrichNewBooks: next.autoEnrichNewBooks,
scanScheduleJson: JSON.stringify(next.scanSchedule),
enrichScheduleJson: JSON.stringify(next.enrichSchedule),
updatedAt: this.database.now()
})
.where(eq(automationSettings.id, 1))
.run();
this.applyRuntimeSettings();
return this.getSettings();
}
runScanNow() {
return this.scanner.enqueueAllLibrariesScan("Manual automation scan");
}
runEnrichNow() {
return this.scanner.enqueueMetadataEnrichment("Manual metadata enrichment");
}
private applyRuntimeSettings(): void {
const settings = this.getSettings();
settings.watchLibraries ? this.startWatchers() : this.stopWatchers();
this.configureSchedules(settings);
}
private startWatchers(): void {
const enabledLibraries = this.database.db.select().from(libraries).where(eq(libraries.enabled, true)).all();
const enabledIds = new Set(enabledLibraries.map((library) => library.id));
for (const [id, entry] of this.watchers) {
if (!enabledIds.has(id)) {
entry.watchers.forEach((watcher) => watcher.close());
if (entry.timer) clearTimeout(entry.timer);
this.watchers.delete(id);
}
}
for (const library of enabledLibraries) {
if (this.watchers.has(library.id)) continue;
const watchers = watchLibraryDirs(library.path, (_event, filename) => {
if (filename && !isBookPath(String(filename))) return;
const current = this.watchers.get(library.id);
if (!current) return;
if (current.timer) clearTimeout(current.timer);
current.timer = setTimeout(() => {
current.timer = null;
this.scanner.enqueueLibraryScan(library.id);
}, 1500);
});
for (const watcher of watchers) {
watcher.on("error", () => {
this.watchers.delete(library.id);
});
}
this.watchers.set(library.id, { watchers, timer: null });
}
}
private stopWatchers(): void {
for (const entry of this.watchers.values()) {
entry.watchers.forEach((watcher) => watcher.close());
if (entry.timer) clearTimeout(entry.timer);
}
this.watchers.clear();
}
private configureSchedules(settings: AutomationSettingsDto): void {
this.clearSchedules();
this.scanTimer = scheduleNext(settings.scanSchedule, () => {
this.scanner.enqueueAllLibrariesScan("Scheduled library scan");
this.configureSchedules(this.getSettings());
});
this.enrichTimer = scheduleNext(settings.enrichSchedule, () => {
this.scanner.enqueueMetadataEnrichment("Scheduled metadata enrichment");
this.configureSchedules(this.getSettings());
});
}
private clearSchedules(): void {
if (this.scanTimer) clearTimeout(this.scanTimer);
if (this.enrichTimer) clearTimeout(this.enrichTimer);
this.scanTimer = null;
this.enrichTimer = null;
}
private readRow(): typeof automationSettings.$inferSelect {
return this.database.db.select().from(automationSettings).where(eq(automationSettings.id, 1)).get()!;
}
}
export function scheduleNext(schedule: AutomationScheduleDto, run: () => void, now = new Date()): NodeJS.Timeout | null {
if (schedule.frequency === "disabled") return null;
const next = nextRunAt(schedule, now);
return setTimeout(run, Math.max(1000, next.getTime() - now.getTime()));
}
export function nextRunAt(schedule: AutomationScheduleDto, now = new Date()): Date {
const [hour, minute] = schedule.time.split(":").map(Number);
const next = new Date(now);
next.setUTCHours(hour, minute, 0, 0);
if (schedule.frequency === "weekly") {
const delta = (schedule.dayOfWeek - next.getUTCDay() + 7) % 7;
next.setUTCDate(next.getUTCDate() + delta);
}
if (next <= now) {
next.setUTCDate(next.getUTCDate() + (schedule.frequency === "weekly" ? 7 : 1));
}
return next;
}
function parseSchedule(value: string): AutomationScheduleDto {
try {
return normalizeSchedule(JSON.parse(value) as Partial<AutomationScheduleDto>);
} catch {
return defaultSchedule;
}
}
function normalizeSchedule(value: Partial<AutomationScheduleDto>): AutomationScheduleDto {
return {
frequency: value.frequency ?? "disabled",
time: value.time ?? "03:00",
dayOfWeek: value.dayOfWeek ?? 1
};
}
function isBookPath(filePath: string): boolean {
return /\.(epub|pdf|cbz|cbr)$/i.test(filePath);
}
function watchLibraryDirs(root: string, listener: (event: string, filename: string | Buffer | null) => void): FSWatcher[] {
const watchers: FSWatcher[] = [];
for (const dir of walkDirs(root)) {
watchers.push(watch(dir, listener));
}
return watchers;
}
function* walkDirs(root: string): Generator<string> {
yield root;
let entries: Dirent[];
try {
entries = readdirSync(root, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
yield* walkDirs(join(root, entry.name));
}
}
}