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:
38
apps/api/src/metadata/adapters/bnf.provider.ts
Normal file
38
apps/api/src/metadata/adapters/bnf.provider.ts
Normal 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];
|
||||
}
|
||||
47
apps/api/src/metadata/adapters/google-books.provider.ts
Normal file
47
apps/api/src/metadata/adapters/google-books.provider.ts
Normal 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);
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
33
apps/api/src/metadata/adapters/open-library.provider.ts
Normal file
33
apps/api/src/metadata/adapters/open-library.provider.ts
Normal 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]);
|
||||
}
|
||||
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");
|
||||
});
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user