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

@ -1,19 +1,19 @@
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 { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
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 { MetadataService } from "../metadata/metadata.service.js";
import { extractMetadata } from "./metadata.js";
import { OpenLibraryService } from "./open-library.service.js";
@Injectable()
export class ScannerService {
constructor(
private readonly database: DatabaseService,
private readonly jobs: JobsService,
private readonly openLibrary: OpenLibraryService
private readonly metadata: MetadataService
) {}
enqueueLibraryScan(libraryId: number) {
@ -28,36 +28,75 @@ export class ScannerService {
return job;
}
enqueueAllLibrariesScan(detail = "Scanning all enabled libraries") {
const job = this.jobs.create("library-scan-all", detail);
setImmediate(() => {
void this.scanAllLibraries(job.id).catch((error) => this.jobs.markFailed(job.id, error));
});
return job;
}
enqueueMetadataEnrichment(detail = "Enriching existing books") {
const job = this.jobs.create("metadata-enrich", detail);
setImmediate(() => {
void this.enrichExistingBooks(job.id).catch((error) => this.jobs.markFailed(job.id, error));
});
return job;
}
private async scanLibrary(jobId: number, library: typeof libraries.$inferSelect): Promise<void> {
this.jobs.markRunning(jobId, `Scanning ${library.path}`);
let count = 0;
const seen = new Set<string>();
for (const filePath of walkBooks(library.path)) {
seen.add(filePath);
await this.ingestFile(library.id, filePath);
count += 1;
}
this.jobs.markSucceeded(jobId, `Scanned ${count} file(s)`);
const removed = this.removeMissingBooks(library.id, seen);
this.jobs.markSucceeded(jobId, `Scanned ${count} file(s), removed ${removed} missing book(s)`);
}
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;
for (const library of enabledLibraries) {
for (const filePath of walkBooks(library.path)) {
await this.ingestFile(library.id, filePath);
scanned += 1;
}
}
this.jobs.markSucceeded(jobId, `Scanned ${scanned} file(s) 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> {
const stats = statSync(filePath);
let metadata = 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 localMetadata = await extractMetadata(filePath, this.database.config.storageDir);
const now = this.database.now();
const format = bookFormatFromPath(filePath);
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get();
const shouldRemoteEnrich = !existing ? this.shouldAutoEnrichNewBooks() : true;
const metadata = await this.metadata.enrichMetadata(localMetadata, filePath, { remote: shouldRemoteEnrich });
const values = {
libraryId,
title: metadata.title,
author: metadata.author,
description: metadata.description,
isbn: metadata.isbn,
isbn13: metadata.isbn13,
identifiersJson: metadata.identifiersJson,
language: metadata.language,
publisher: metadata.publisher,
publishedDate: metadata.publishedDate,
@ -73,6 +112,24 @@ export class ScannerService {
? 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
);
}
}
function* walkBooks(root: string): Generator<string> {