Files
ReadaBook/apps/api/src/metadata/adapters/open-library.provider.ts
Git Agent d79f502dd2 feat(api,web): providers ComicVine + MangaDex et pilotage de l'enrichissement
- Adaptateurs comic-vine et mangadex avec helper de fetch partagé,
  timeouts et fallback durcis sur les providers existants
- Scoring des correspondances amélioré, normalisation de la date de
  publication, chaîne de résolution des providers étendue
- Scanner : statuts par livre (scan/enrichissement) et page admin
  d'automatisation alignée

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 09:11:18 +02:00

149 lines
6.8 KiB
TypeScript

import { Injectable } from "@nestjs/common";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
import { providerFetch, providerHttpError } from "./provider-fetch.js";
@Injectable()
export class OpenLibraryProvider implements MetadataProvider {
readonly id = "openlibrary" as const;
async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise<MetadataMatch | null> {
const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10;
if (isbn) {
return this.lookupIsbn(isbn, lookup.identifiers.isbn13);
}
if (lookup.sourceId) {
return this.lookupEdition(lookup.sourceId, lookup.identifiers.isbn13);
}
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, _config);
return matches[0] ?? null;
}
async searchByMetadata(query: MetadataSearchQuery, _config: MetadataProviderConfig): Promise<MetadataMatch[]> {
const url = new URL("https://openlibrary.org/search.json");
url.searchParams.set("title", query.title);
if (query.author) url.searchParams.set("author", query.author);
if (query.year) url.searchParams.set("first_publish_year", query.year);
url.searchParams.set("limit", "5");
const response = await providerFetch(this.id, url, {
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
timeoutMs: 4000
});
if (!response.ok) throw await providerHttpError(this.id, response, `OpenLibrary HTTP ${response.status}`);
const data = (await response.json()) as { docs?: Array<Record<string, unknown>> };
return (data.docs ?? []).map((doc) => ({
title: stringValue(doc.title) ?? undefined,
sourceId: firstArrayValue(doc.edition_key) ?? stringValue(doc.cover_edition_key),
author: arrayJoin(doc.author_name),
language: firstArrayValue(doc.language),
publisher: firstArrayValue(doc.publisher),
publishedDate: normalizePublishedDate(String(doc.first_publish_year ?? "") || null),
isbn: bestIsbn(doc.isbn, query.isbn ? toIsbn13(query.isbn) : null),
coverUrl: openLibraryCoverUrl(doc.cover_i, firstArrayValue(doc.edition_key) ?? stringValue(doc.cover_edition_key))
}));
}
private async lookupEdition(sourceId: string, expectedIsbn13: string | null): Promise<MetadataMatch | null> {
const editionKey = sourceId.replace(/^\/?books\//, "");
if (!editionKey) return null;
const response = await providerFetch(this.id, `https://openlibrary.org/books/${encodeURIComponent(editionKey)}.json`, {
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
timeoutMs: 4000
});
if (response.status === 404) return null;
if (!response.ok) throw await providerHttpError(this.id, response, `OpenLibrary HTTP ${response.status}`);
return this.editionToMatch((await response.json()) as Record<string, unknown>, expectedIsbn13);
}
private async lookupIsbn(isbn: string, expectedIsbn13: string | null): Promise<MetadataMatch | null> {
const response = await providerFetch(this.id, `https://openlibrary.org/isbn/${encodeURIComponent(isbn)}.json`, {
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
timeoutMs: 4000
});
if (response.status === 404) return null;
if (!response.ok) throw await providerHttpError(this.id, response, `OpenLibrary HTTP ${response.status}`);
return this.editionToMatch((await response.json()) as Record<string, unknown>, expectedIsbn13);
}
private async editionToMatch(edition: Record<string, unknown>, expectedIsbn13: string | null): Promise<MetadataMatch> {
const author = await this.lookupAuthorName(edition.authors);
return {
title: stringValue(edition.title) ?? undefined,
author,
description: descriptionValue(edition.description),
isbn: bestIsbn([...(asStringArray(edition.isbn_13)), ...(asStringArray(edition.isbn_10))], expectedIsbn13),
language: languageValue(edition.languages),
publisher: firstArrayValue(edition.publishers),
publishedDate: normalizePublishedDate(stringValue(edition.publish_date)),
coverUrl: editionCoverUrl(edition)
};
}
private async lookupAuthorName(value: unknown): Promise<string | null> {
const key = (Array.isArray(value) ? value[0] : undefined)?.key;
if (typeof key !== "string") return null;
const response = await providerFetch(this.id, `https://openlibrary.org${key}.json`, {
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
timeoutMs: 3000
});
if (response.status === 404) return null;
if (!response.ok) throw await providerHttpError(this.id, response, `OpenLibrary HTTP ${response.status}`);
const author = (await response.json()) as Record<string, unknown>;
return stringValue(author.name);
}
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function firstArrayValue(value: unknown): string | null {
if (!Array.isArray(value) || !value.length) return null;
return String(value[0]);
}
function arrayJoin(value: unknown): string | null {
return Array.isArray(value) && value.length ? value.map(String).join(", ") : null;
}
function bestIsbn(value: unknown, expectedIsbn13: string | null): string | null {
if (!Array.isArray(value)) return null;
const values = value.map(String);
return (
values.find((candidate) => Boolean(expectedIsbn13) && toIsbn13(candidate) === expectedIsbn13) ??
values.find((candidate) => Boolean(toIsbn13(candidate))) ??
null
);
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function descriptionValue(value: unknown): string | null {
if (typeof value === "string") return value.trim() || null;
if (typeof value === "object" && value && "value" in value) return stringValue(value.value);
return null;
}
function languageValue(value: unknown): string | null {
const key = (Array.isArray(value) ? value[0] : undefined)?.key;
return typeof key === "string" ? key.split("/").pop() ?? null : null;
}
function openLibraryCoverUrl(coverId: unknown, editionKey: string | null): string | null {
if (typeof coverId === "number" || typeof coverId === "string") {
return `https://covers.openlibrary.org/b/id/${encodeURIComponent(String(coverId))}-L.jpg`;
}
if (editionKey) {
return `https://covers.openlibrary.org/b/olid/${encodeURIComponent(editionKey)}-L.jpg`;
}
return null;
}
function editionCoverUrl(edition: Record<string, unknown>): string | null {
const covers = Array.isArray(edition.covers) ? edition.covers : [];
return openLibraryCoverUrl(covers[0], stringValue(edition.key)?.split("/").pop() ?? null);
}