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 { 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 { 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> }; 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 { 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, expectedIsbn13); } private async lookupIsbn(isbn: string, expectedIsbn13: string | null): Promise { 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, expectedIsbn13); } private async editionToMatch(edition: Record, expectedIsbn13: string | null): Promise { 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 { 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; 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 | null { const covers = Array.isArray(edition.covers) ? edition.covers : []; return openLibraryCoverUrl(covers[0], stringValue(edition.key)?.split("/").pop() ?? null); }