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:
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user