feat(metadata): introduce provider architecture for book metadata

This commit is contained in:
neon
2026-02-19 01:55:40 -06:00
parent 3ff1cd5882
commit 1dd6381325
28 changed files with 700 additions and 13 deletions
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
import { ImagePlus, Link, RotateCcw, Upload } from 'lucide-vue-next'
import { ImagePlus, Link, RotateCcw, Upload, X } from 'lucide-vue-next'
import type { BookDetail } from '@projectx/types'
import { useCoverEditor } from '../../../composables/useCoverEditor'
import { useCoverVersions } from '../../../composables/useCoverVersions'
@@ -46,13 +46,15 @@ async function handleRevert() {
if (result !== false) emit('coverChanged', result)
}
const lightboxOpen = ref(false)
onUnmounted(() => clearTimeout(debounceTimer))
</script>
<template>
<div class="flex flex-col gap-3">
<!-- Cover image -->
<div class="relative w-full overflow-hidden rounded-xl bg-muted shadow-md" style="aspect-ratio: 2/3">
<div class="relative w-full overflow-hidden rounded-xl bg-muted shadow-md cursor-zoom-in" style="aspect-ratio: 2/3" @click="lightboxOpen = true">
<img
:src="activeSrc"
:alt="book.title ?? ''"
@@ -62,6 +64,25 @@ onUnmounted(() => clearTimeout(debounceTimer))
/>
</div>
<!-- Lightbox -->
<Teleport to="body">
<div
v-if="lightboxOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
@click="lightboxOpen = false"
>
<button class="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors" @click="lightboxOpen = false">
<X class="size-5" />
</button>
<img
:src="activeSrc"
:alt="book.title ?? ''"
class="max-h-[90vh] max-w-[90vw] rounded-xl shadow-2xl object-contain"
@click.stop
/>
</div>
</Teleport>
<!-- Mode toggle -->
<div class="flex gap-1 p-0.5 rounded-lg bg-muted">
<button
+1 -4
View File
@@ -214,10 +214,7 @@ watch(loading, (isLoading) => {
>
<template #actions>
<button
@click="
editorOpen = true
confirmDelete = false
"
@click="editorOpen = true; confirmDelete = false"
class="hidden md:flex items-center gap-1.5 h-8 px-3 rounded-md border border-input text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Settings2 :size="13" />
+1
View File
@@ -11,6 +11,7 @@ export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@projectx/types': fileURLToPath(new URL('../packages/types/src/index.ts', import.meta.url)),
},
},
server: {
+8 -2
View File
@@ -2,6 +2,12 @@
"name": "@projectx/types",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts"
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"devDependencies": {
"typescript": "^5.0.0"
},
"scripts": {
"build": "tsc"
}
}
+1
View File
@@ -2,6 +2,7 @@ export * from "./auth";
export * from "./book";
export * from "./collection";
export * from "./library";
export * from "./metadata-fetch";
export * from "./query";
export * from "./reader-settings";
export * from "./scanner";
+35
View File
@@ -0,0 +1,35 @@
export const MetadataProviderKey = {
GOOGLE: 'google',
GOODREADS: 'goodreads',
AMAZON: 'amazon',
HARDCOVER: 'hardcover',
OPEN_LIBRARY: 'openLibrary',
} as const;
export type MetadataProviderKey = (typeof MetadataProviderKey)[keyof typeof MetadataProviderKey];
export interface MetadataCandidate {
provider: MetadataProviderKey;
providerId: string;
title: string;
subtitle?: string;
authors?: string[];
description?: string;
publisher?: string;
publishedYear?: number;
language?: string;
pageCount?: number;
isbn10?: string;
isbn13?: string;
seriesName?: string;
seriesIndex?: number;
tags?: string[];
coverUrl?: string;
sourceUrl?: string;
}
export interface MetadataProviderInfo {
key: MetadataProviderKey;
label: string;
identifiable: boolean;
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"strict": false,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+4 -2
View File
@@ -3,7 +3,7 @@ import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { appConfig, authConfig, dbConfig, mailerConfig, storageConfig } from './config/config';
import { appConfig, authConfig, dbConfig, externalApiConfig, mailerConfig, storageConfig } from './config/config';
import { CommonModule } from './common/common.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { LibraryAccessGuard } from './common/guards/library-access.guard';
@@ -23,6 +23,7 @@ import { LibraryModule } from './modules/library/library.module';
import { LensModule } from './modules/lens/lens.module';
import { OpdsModule } from './modules/opds/opds.module';
import { PathModule } from './modules/path/path.module';
import { MetadataFetchModule } from './modules/metadata-fetch/metadata-fetch.module';
import { MetadataModule } from './modules/metadata/metadata.module';
import { ReaderPreferencesModule } from './modules/reader-preferences/reader-preferences.module';
import { RoleModule } from './modules/role/role.module';
@@ -34,7 +35,7 @@ import { UserModule } from './modules/user/user.module';
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, dbConfig, authConfig, storageConfig, mailerConfig],
load: [appConfig, dbConfig, authConfig, storageConfig, mailerConfig, externalApiConfig],
}),
ScheduleModule.forRoot(),
DbModule,
@@ -53,6 +54,7 @@ import { UserModule } from './modules/user/user.module';
LensModule,
ScannerModule,
MetadataModule,
MetadataFetchModule,
KoboModule,
OpdsModule,
BookmarkModule,
+4
View File
@@ -19,6 +19,10 @@ export const storageConfig = registerAs('storage', () => ({
booksPath: process.env.BOOKS_PATH ?? '/data/books',
}));
export const externalApiConfig = registerAs('externalApi', () => ({
googleBooksApiKey: process.env.GOOGLE_BOOKS_API_KEY ?? '',
}));
export const mailerConfig = registerAs('mailer', () => ({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT ?? '587', 10),
@@ -198,6 +198,11 @@ CREATE TABLE "book_metadata" (
"series_index" real,
"rating" integer,
"cover_source" varchar(9),
"google_books_id" varchar(50),
"goodreads_id" varchar(50),
"amazon_id" varchar(20),
"hardcover_id" varchar(50),
"open_library_id" varchar(50),
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
@@ -1,5 +1,5 @@
{
"id": "388fa65b-91c4-4c20-9bca-e95c235d5cf8",
"id": "f572f92e-eb3a-4b8c-8a8a-bde7de427787",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -1461,6 +1461,36 @@
"primaryKey": false,
"notNull": false
},
"google_books_id": {
"name": "google_books_id",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"goodreads_id": {
"name": "goodreads_id",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"amazon_id": {
"name": "amazon_id",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false
},
"hardcover_id": {
"name": "hardcover_id",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"open_library_id": {
"name": "open_library_id",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
+2 -2
View File
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1771401479472,
"tag": "0000_baseline",
"when": 1771534071911,
"tag": "0000_add_provider_ids",
"breakpoints": true
}
]
+5
View File
@@ -19,6 +19,11 @@ export const bookMetadata = pgTable('book_metadata', {
seriesIndex: real('series_index'),
rating: integer('rating'),
coverSource: varchar('cover_source', { length: 9 }),
googleBooksId: varchar('google_books_id', { length: 50 }),
goodreadsId: varchar('goodreads_id', { length: 50 }),
amazonId: varchar('amazon_id', { length: 20 }),
hardcoverId: varchar('hardcover_id', { length: 50 }),
openLibraryId: varchar('open_library_id', { length: 50 }),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
@@ -0,0 +1 @@
export const METADATA_PROVIDERS = Symbol('METADATA_PROVIDERS');
@@ -0,0 +1,12 @@
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { MetadataProviderKey } from '@projectx/types';
export class LookupMetadataDto {
@IsEnum(MetadataProviderKey)
provider: MetadataProviderKey;
@IsString()
@IsNotEmpty()
id: string;
}
@@ -0,0 +1,64 @@
import { Transform, Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { MetadataProviderKey } from '@projectx/types';
@ValidatorConstraint({ name: 'atLeastOneSearchTerm', async: false })
class AtLeastOneSearchTermConstraint implements ValidatorConstraintInterface {
validate(_: unknown, args: ValidationArguments): boolean {
const obj = args.object as MetadataSearchDto;
return !!(obj.bookId || obj.title?.trim() || obj.isbn?.trim());
}
defaultMessage(): string {
return 'At least one of bookId, title, or isbn must be provided';
}
}
function AtLeastOneSearchTerm(options?: ValidationOptions) {
return function (constructor: new (...args: unknown[]) => unknown) {
registerDecorator({
name: 'atLeastOneSearchTerm',
target: constructor,
propertyName: '',
options,
constraints: [],
validator: AtLeastOneSearchTermConstraint,
});
};
}
@AtLeastOneSearchTerm()
export class MetadataSearchDto {
@IsOptional()
@IsInt()
@Type(() => Number)
bookId?: number;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
isbn?: string;
@IsOptional()
@Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.split(',') : (value as MetadataProviderKey[])))
@IsEnum(MetadataProviderKey, { each: true })
providers?: MetadataProviderKey[];
}
@@ -0,0 +1,45 @@
import { Controller, Get, MessageEvent, Query, Sse } from '@nestjs/common';
import { MetadataCandidate, MetadataProviderInfo } from '@projectx/types';
import { map, Observable } from 'rxjs';
import { LookupMetadataDto } from './dto/lookup-metadata.dto';
import { MetadataSearchDto } from './dto/metadata-search.dto';
import { MetadataFetchService } from './metadata-fetch.service';
import { ProviderRegistry } from './provider-registry';
import { MetadataSearchParams } from './providers/metadata-search-params';
@Controller('metadata-fetch')
export class MetadataFetchController {
constructor(
private readonly metadataFetchService: MetadataFetchService,
private readonly registry: ProviderRegistry,
) {}
@Get('providers')
listProviders(): MetadataProviderInfo[] {
return this.registry.all().map((p) => ({
key: p.key,
label: p.label,
identifiable: p.identifiable,
}));
}
@Sse('stream')
async stream(@Query() dto: MetadataSearchDto): Promise<Observable<MessageEvent>> {
const existingProviderIds = dto.bookId ? await this.metadataFetchService.getStoredProviderIds(dto.bookId) : {};
const params: MetadataSearchParams = {
title: dto.title,
author: dto.author,
isbn: dto.isbn,
existingProviderIds,
};
return this.metadataFetchService.search(params, dto.providers).pipe(map((candidate: MetadataCandidate) => ({ data: candidate })));
}
@Get('lookup')
async lookup(@Query() dto: LookupMetadataDto): Promise<MetadataCandidate | null> {
return this.metadataFetchService.lookupById(dto.provider, dto.id);
}
}
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { METADATA_PROVIDERS } from './constants';
import { MetadataFetchController } from './metadata-fetch.controller';
import { MetadataFetchService } from './metadata-fetch.service';
import { ProviderRegistry } from './provider-registry';
import { GoogleProvider } from './providers/google/google.provider';
import { OpenLibraryProvider } from './providers/open-library/open-library.provider';
const PROVIDER_CLASSES = [GoogleProvider, OpenLibraryProvider];
@Module({
providers: [
...PROVIDER_CLASSES,
...PROVIDER_CLASSES.map((cls) => ({
provide: METADATA_PROVIDERS,
useExisting: cls,
multi: true,
})),
ProviderRegistry,
MetadataFetchService,
],
controllers: [MetadataFetchController],
})
export class MetadataFetchModule {}
@@ -0,0 +1,72 @@
import { Inject, Injectable } from '@nestjs/common';
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { from, merge, Observable, switchMap } from 'rxjs';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookMetadata } from '../../db/schema';
import { ProviderRegistry } from './provider-registry';
import { isIdentifiable, MetadataProvider } from './providers/metadata-provider';
import { MetadataSearchParams } from './providers/metadata-search-params';
type Db = NodePgDatabase<typeof schema>;
@Injectable()
export class MetadataFetchService {
private static readonly PROVIDER_TIMEOUT_MS = 15_000;
constructor(
private readonly registry: ProviderRegistry,
@Inject(DB) private readonly db: Db,
) {}
search(params: MetadataSearchParams, keys?: MetadataProviderKey[]): Observable<MetadataCandidate> {
const providers = this.registry.select(keys);
return merge(...providers.map((p) => from(this.withTimeout(this.fetchFromProvider(p, params))).pipe(switchMap((results) => from(results)))));
}
async lookupById(key: MetadataProviderKey, providerId: string): Promise<MetadataCandidate | null> {
const provider = this.registry.find(key);
if (!provider || !isIdentifiable(provider)) return null;
return provider.lookupById(providerId);
}
async getStoredProviderIds(bookId: number): Promise<Partial<Record<MetadataProviderKey, string>>> {
const row = await this.db.query.bookMetadata.findFirst({
where: eq(bookMetadata.bookId, bookId),
columns: {
googleBooksId: true,
goodreadsId: true,
amazonId: true,
hardcoverId: true,
openLibraryId: true,
},
});
if (!row) return {};
return {
[MetadataProviderKey.GOOGLE]: row.googleBooksId ?? undefined,
[MetadataProviderKey.GOODREADS]: row.goodreadsId ?? undefined,
[MetadataProviderKey.AMAZON]: row.amazonId ?? undefined,
[MetadataProviderKey.HARDCOVER]: row.hardcoverId ?? undefined,
[MetadataProviderKey.OPEN_LIBRARY]: row.openLibraryId ?? undefined,
};
}
private fetchFromProvider(p: MetadataProvider, params: MetadataSearchParams): Promise<MetadataCandidate[]> {
const existingId = params.existingProviderIds?.[p.key];
if (isIdentifiable(p) && existingId) {
return p.lookupById(existingId).then((r) => (r ? [r] : []));
}
return p.search(params);
}
private withTimeout(promise: Promise<MetadataCandidate[]>): Promise<MetadataCandidate[]> {
let timer: NodeJS.Timeout;
const timeout = new Promise<MetadataCandidate[]>((resolve) => {
timer = setTimeout(() => resolve([]), MetadataFetchService.PROVIDER_TIMEOUT_MS);
});
return Promise.race([promise.catch(() => [] as MetadataCandidate[]), timeout]).finally(() => clearTimeout(timer!));
}
}
@@ -0,0 +1,30 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { MetadataProviderKey } from '@projectx/types';
import { METADATA_PROVIDERS } from './constants';
import { MetadataProvider } from './providers/metadata-provider';
@Injectable()
export class ProviderRegistry {
constructor(
@Inject(METADATA_PROVIDERS)
private readonly providers: MetadataProvider[],
) {}
all(): MetadataProvider[] {
return this.providers;
}
select(keys?: MetadataProviderKey[]): MetadataProvider[] {
if (!keys?.length) return this.providers;
const known = new Set(this.providers.map((p) => p.key));
const unknown = keys.filter((k) => !known.has(k));
if (unknown.length) throw new BadRequestException(`Unknown providers: ${unknown.join(', ')}`);
const requested = new Set(keys);
return this.providers.filter((p) => requested.has(p.key));
}
find(key: MetadataProviderKey): MetadataProvider | undefined {
return this.providers.find((p) => p.key === key);
}
}
@@ -0,0 +1,41 @@
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { GoogleVolumeItem } from './google.types';
function parseYear(dateString: string | undefined): number | undefined {
if (!dateString) return undefined;
const year = parseInt(dateString.substring(0, 4), 10);
return Number.isNaN(year) ? undefined : year;
}
export function mapGoogleVolume(raw: GoogleVolumeItem): MetadataCandidate {
const info = raw.volumeInfo;
const identifiers = info.industryIdentifiers ?? [];
const publishedYear = parseYear(info.publishedDate);
const isbn10 = identifiers.find((i) => i.type === 'ISBN_10')?.identifier;
const isbn13 = identifiers.find((i) => i.type === 'ISBN_13')?.identifier;
const coverUrl = info.imageLinks?.thumbnail
? info.imageLinks.thumbnail.replace('http://', 'https://').replace('&edge=curl', '').replace('zoom=1', 'zoom=0')
: undefined;
return {
provider: MetadataProviderKey.GOOGLE,
providerId: raw.id,
title: info.title,
subtitle: info.subtitle,
authors: info.authors,
description: info.description,
publisher: info.publisher,
publishedYear,
language: info.language,
pageCount: info.pageCount,
isbn10,
isbn13,
tags: info.categories,
coverUrl,
sourceUrl: `https://books.google.com/books?id=${raw.id}`,
};
}
@@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { IdentifiableProvider } from '../metadata-provider';
import { MetadataSearchParams } from '../metadata-search-params';
import { mapGoogleVolume } from './google.mapper';
import { GoogleBooksResponse, GoogleVolumeItem } from './google.types';
const BASE_URL = 'https://www.googleapis.com/books/v1';
@Injectable()
export class GoogleProvider implements IdentifiableProvider {
readonly key = MetadataProviderKey.GOOGLE;
readonly label = 'Google Books';
readonly identifiable = true as const;
private readonly logger = new Logger(GoogleProvider.name);
private readonly apiKey: string;
constructor(private readonly config: ConfigService) {
this.apiKey = this.config.get<string>('externalApi.googleBooksApiKey') ?? '';
}
async search(params: MetadataSearchParams): Promise<MetadataCandidate[]> {
const query = this.buildQuery(params);
if (!query) return [];
return this.fetchVolumes(query);
}
async lookupById(providerId: string): Promise<MetadataCandidate | null> {
const url = this.buildUrl(`/volumes/${providerId}`);
const res = await fetch(url);
if (!res.ok) {
this.logIfConfigError(res.status, `lookupById(${providerId})`);
return null;
}
const item = (await res.json()) as GoogleVolumeItem;
return mapGoogleVolume(item);
}
private buildQuery(params: MetadataSearchParams): string | null {
if (params.isbn) return `isbn:${params.isbn}`;
if (params.title && params.author) return `intitle:${params.title}+inauthor:${params.author}`;
if (params.title) return `intitle:${params.title}`;
return null;
}
private async fetchVolumes(query: string): Promise<MetadataCandidate[]> {
const url = this.buildUrl('/volumes', { q: query, maxResults: '10', printType: 'books' });
const res = await fetch(url);
if (!res.ok) {
this.logIfConfigError(res.status, `search("${query}")`);
return [];
}
const body = (await res.json()) as GoogleBooksResponse;
return (body.items ?? []).map(mapGoogleVolume);
}
private buildUrl(path: string, extra: Record<string, string> = {}): string {
const params = new URLSearchParams(extra);
if (this.apiKey) params.set('key', this.apiKey);
const qs = params.toString();
return `${BASE_URL}${path}${qs ? `?${qs}` : ''}`;
}
private logIfConfigError(status: number, context: string): void {
if (status === 400 || status === 403) {
this.logger.warn(`Google Books API config error (${status}) during ${context} - check GOOGLE_BOOKS_API_KEY`);
}
}
}
@@ -0,0 +1,31 @@
export interface GoogleBooksResponse {
items?: GoogleVolumeItem[];
totalItems?: number;
}
export interface GoogleVolumeItem {
id: string;
volumeInfo: GoogleVolumeInfo;
}
export interface GoogleVolumeInfo {
title: string;
subtitle?: string;
authors?: string[];
description?: string;
publisher?: string;
publishedDate?: string;
pageCount?: number;
language?: string;
categories?: string[];
industryIdentifiers?: GoogleIndustryIdentifier[];
imageLinks?: {
thumbnail?: string;
smallThumbnail?: string;
};
}
export interface GoogleIndustryIdentifier {
type: 'ISBN_10' | 'ISBN_13' | 'OTHER';
identifier: string;
}
@@ -0,0 +1,19 @@
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { MetadataSearchParams } from './metadata-search-params';
export interface MetadataProvider {
readonly key: MetadataProviderKey;
readonly label: string;
readonly identifiable: boolean;
search(params: MetadataSearchParams): Promise<MetadataCandidate[]>;
}
export interface IdentifiableProvider extends MetadataProvider {
readonly identifiable: true;
lookupById(providerId: string): Promise<MetadataCandidate | null>;
}
export function isIdentifiable(p: MetadataProvider): p is IdentifiableProvider {
return p.identifiable === true;
}
@@ -0,0 +1,8 @@
import { MetadataProviderKey } from '@projectx/types';
export interface MetadataSearchParams {
title?: string;
author?: string;
isbn?: string;
existingProviderIds?: Partial<Record<MetadataProviderKey, string>>;
}
@@ -0,0 +1,62 @@
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { OpenLibraryDoc, OpenLibraryTextValue, OpenLibraryWork } from './open-library.types';
function extractDescription(raw: string | OpenLibraryTextValue | undefined): string | undefined {
if (!raw) return undefined;
if (typeof raw === 'string') return raw;
return raw.value;
}
function stripWorkPrefix(key: string): string {
return key.replace(/^\/works\//, '');
}
function parseYear(dateString: string | undefined): number | undefined {
if (!dateString) return undefined;
const year = parseInt(dateString.substring(0, 4), 10);
return Number.isNaN(year) ? undefined : year;
}
function coverUrl(coverId: number | undefined): string | undefined {
if (!coverId) return undefined;
return `https://covers.openlibrary.org/b/id/${coverId}-L.jpg`;
}
export function mapOpenLibraryDoc(raw: OpenLibraryDoc): MetadataCandidate {
const isbn10 = raw.isbn?.find((i) => i.length === 10);
const isbn13 = raw.isbn?.find((i) => i.length === 13);
const providerId = stripWorkPrefix(raw.key);
return {
provider: MetadataProviderKey.OPEN_LIBRARY,
providerId,
title: raw.title,
authors: raw.author_name,
publishedYear: raw.first_publish_year,
isbn10,
isbn13,
publisher: raw.publisher?.[0],
language: raw.language?.[0],
pageCount: raw.number_of_pages_median,
tags: raw.subject?.slice(0, 10),
coverUrl: coverUrl(raw.cover_i),
sourceUrl: `https://openlibrary.org${raw.key}`,
};
}
export function mapOpenLibraryWork(raw: OpenLibraryWork): MetadataCandidate {
const providerId = stripWorkPrefix(raw.key);
const firstCover = raw.covers?.[0];
return {
provider: MetadataProviderKey.OPEN_LIBRARY,
providerId,
title: raw.title,
description: extractDescription(raw.description),
publishedYear: parseYear(raw.first_publish_date),
tags: raw.subjects?.slice(0, 10),
coverUrl: coverUrl(firstCover),
sourceUrl: `https://openlibrary.org${raw.key}`,
};
}
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { IdentifiableProvider } from '../metadata-provider';
import { MetadataSearchParams } from '../metadata-search-params';
import { mapOpenLibraryDoc, mapOpenLibraryWork } from './open-library.mapper';
import { OpenLibrarySearchResponse, OpenLibraryWork } from './open-library.types';
const BASE_URL = 'https://openlibrary.org';
const SEARCH_FIELDS = 'key,title,author_name,first_publish_year,isbn,cover_i,publisher,language,number_of_pages_median,subject';
@Injectable()
export class OpenLibraryProvider implements IdentifiableProvider {
readonly key = MetadataProviderKey.OPEN_LIBRARY;
readonly label = 'OpenLibrary';
readonly identifiable = true as const;
async search(params: MetadataSearchParams): Promise<MetadataCandidate[]> {
const query = this.buildSearchParams(params);
if (!query) return [];
query.set('limit', '10');
query.set('fields', SEARCH_FIELDS);
const res = await fetch(`${BASE_URL}/search.json?${query}`);
if (!res.ok) return [];
const body = (await res.json()) as OpenLibrarySearchResponse;
return body.docs.map(mapOpenLibraryDoc);
}
async lookupById(providerId: string): Promise<MetadataCandidate | null> {
const res = await fetch(`${BASE_URL}/works/${providerId}.json`);
if (!res.ok) return null;
const work = (await res.json()) as OpenLibraryWork;
return mapOpenLibraryWork(work);
}
private buildSearchParams(params: MetadataSearchParams): URLSearchParams | null {
if (!params.isbn && !params.title) return null;
const query = new URLSearchParams();
if (params.isbn) {
query.set('isbn', params.isbn);
} else {
query.set('title', params.title!);
if (params.author) query.set('author', params.author);
}
return query;
}
}
@@ -0,0 +1,32 @@
export interface OpenLibrarySearchResponse {
docs: OpenLibraryDoc[];
numFound: number;
}
export interface OpenLibraryDoc {
key: string;
title: string;
author_name?: string[];
first_publish_year?: number;
isbn?: string[];
cover_i?: number;
publisher?: string[];
language?: string[];
number_of_pages_median?: number;
subject?: string[];
series?: string[];
}
export interface OpenLibraryWork {
key: string;
title: string;
description?: string | OpenLibraryTextValue;
subjects?: string[];
covers?: number[];
first_publish_date?: string;
}
export interface OpenLibraryTextValue {
type: string;
value: string;
}