mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): introduce provider architecture for book metadata
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
+5
@@ -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",
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1771401479472,
|
||||
"tag": "0000_baseline",
|
||||
"when": 1771534071911,
|
||||
"tag": "0000_add_provider_ids",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user