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,38 @@
import { Injectable } from "@nestjs/common";
import { XMLParser } from "fast-xml-parser";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
@Injectable()
export class BnfProvider implements MetadataProvider {
readonly id = "bnf" as const;
async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise<MetadataMatch | null> {
const query = lookup.identifiers.isbn13
? `bib.isbn all "${lookup.identifiers.isbn13}"`
: `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;
if (!record) return null;
const text = JSON.stringify(record);
return {
title: match(text, /"titleInfo"[^}]*"title":"([^"]+)"/),
author: match(text, /"namePart":"([^"]+)"/),
publisher: match(text, /"publisher":"([^"]+)"/),
publishedDate: match(text, /"dateIssued":"([^"]+)"/)
};
}
}
function match(value: string, pattern: RegExp): string | undefined {
return value.match(pattern)?.[1];
}

View File

@ -0,0 +1,47 @@
import { Injectable } from "@nestjs/common";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
@Injectable()
export class GoogleBooksProvider implements MetadataProvider {
readonly id = "googlebooks" as const;
async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null> {
const query = lookup.identifiers.isbn13
? `isbn:${lookup.identifiers.isbn13}`
: `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");
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)
};
}
}
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): string | null {
if (!Array.isArray(value)) return null;
const isbn13 = value.find((entry) => entry?.type === "ISBN_13")?.identifier;
const isbn10 = value.find((entry) => entry?.type === "ISBN_10")?.identifier;
return stringValue(isbn13) ?? stringValue(isbn10);
}

View 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
};
}
}

View File

@ -0,0 +1,33 @@
import { Injectable } from "@nestjs/common";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
@Injectable()
export class OpenLibraryProvider implements MetadataProvider {
readonly id = "openlibrary" as const;
async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise<MetadataMatch | null> {
const query = lookup.identifiers.isbn13
? `isbn:${encodeURIComponent(lookup.identifiers.isbn13)}`
: `title:${encodeURIComponent(lookup.title)}${lookup.author ? ` author:${encodeURIComponent(lookup.author)}` : ""}`;
const response = await fetch(`https://openlibrary.org/search.json?q=${query}&limit=1`, {
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 {
author: firstArrayValue(doc.author_name),
language: firstArrayValue(doc.language),
publisher: firstArrayValue(doc.publisher),
publishedDate: String(doc.first_publish_year ?? "") || null,
isbn: firstArrayValue(doc.isbn)
};
}
}
function firstArrayValue(value: unknown): string | null {
if (!Array.isArray(value) || !value.length) return null;
return String(value[0]);
}

View 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");
});
});

View 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 {}

View 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
};
}

View 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>;
}

View 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"]);
});
});

View 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);
}
}

View 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);
}
}