mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(metadata): add Hardcover provider and split settings into tabs
- implement HardcoverProvider (IdentifiableProvider) with ISBN, title+author, title-only search fallback chain - add HardcoverClient with fixed-delay rate limiter using atomic slot reservation - add hardcover.mapper.ts and hardcover.types.ts for clean separation of concerns - fix cached_contributors as JSONB scalar (no subfield selection) - fix null series position normalisation to undefined - fix edition publishedYear fallback to book-level dates - add 37 unit tests across mapper and provider - update metadata-fetch.module.test.ts for HardcoverProvider registration - split MetadataPreferencesSettings into Providers tab and new Field Rules tab - update Hardcover favicon to assets.hardcover.app/static/favicon.ico
This commit is contained in:
@@ -226,7 +226,7 @@ const providerLinks = computed<ProviderLink[]>(() => {
|
||||
key: 'hardcover',
|
||||
label: 'Hardcover',
|
||||
url: `https://hardcover.app/books/${ids.hardcover}`,
|
||||
iconUrl: 'https://hardcover.app/favicon.ico',
|
||||
iconUrl: 'https://assets.hardcover.app/static/favicon.ico',
|
||||
fallback: 'H',
|
||||
})
|
||||
}
|
||||
@@ -425,7 +425,7 @@ async function loadSupplemental() {
|
||||
for (const row of progressRows) {
|
||||
if (!Number.isFinite(row.fileId)) continue
|
||||
progressMap[row.fileId] = {
|
||||
percentage: typeof row.percentage === 'number' ? row.percentage : 0,
|
||||
percentage: row.percentage,
|
||||
cfi: row.cfi,
|
||||
pageNumber: row.pageNumber,
|
||||
updatedAt: row.updatedAt,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import MetadataPreferencesSettings from './metadata-preferences/MetadataPreferencesSettings.vue'
|
||||
import MetadataFieldRulesSettings from './metadata-preferences/MetadataFieldRulesSettings.vue'
|
||||
import MetadataScoreWeightsSettings from './MetadataScoreWeightsSettings.vue'
|
||||
import BookMetadataFetchSettings from './metadata-auto-fetch/BookMetadataFetchSettings.vue'
|
||||
import AuthorEnrichmentSettings from './AuthorEnrichmentSettings.vue'
|
||||
@@ -35,7 +36,8 @@ const tabs = METADATA_TABS.map((id) => ({
|
||||
const activeTabInfo = computed(() => METADATA_TAB_INFO[activeTab.value])
|
||||
|
||||
const tabWidths: Record<Tab, string> = {
|
||||
providers: 'max-w-5xl',
|
||||
providers: 'max-w-3xl',
|
||||
'field-rules': 'max-w-6xl',
|
||||
score: 'max-w-3xl',
|
||||
'auto-fetch': 'max-w-3xl',
|
||||
authors: 'max-w-3xl',
|
||||
@@ -70,6 +72,7 @@ function selectTab(tab: Tab) {
|
||||
|
||||
<div :class="tabWidths[activeTab]">
|
||||
<MetadataPreferencesSettings v-if="activeTab === 'providers'" />
|
||||
<MetadataFieldRulesSettings v-else-if="activeTab === 'field-rules'" />
|
||||
<MetadataScoreWeightsSettings v-else-if="activeTab === 'score'" />
|
||||
<BookMetadataFetchSettings v-else-if="activeTab === 'auto-fetch'" />
|
||||
<AuthorEnrichmentSettings v-else-if="activeTab === 'authors'" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const METADATA_TABS = ['providers', 'score', 'auto-fetch', 'authors', 'file-sync'] as const
|
||||
export const METADATA_TABS = ['providers', 'field-rules', 'score', 'auto-fetch', 'authors', 'file-sync'] as const
|
||||
|
||||
export type MetadataTab = (typeof METADATA_TABS)[number]
|
||||
|
||||
@@ -11,8 +11,13 @@ type MetadataTabInfo = {
|
||||
export const METADATA_TAB_INFO: Record<MetadataTab, MetadataTabInfo> = {
|
||||
providers: {
|
||||
navLabel: 'Providers',
|
||||
titleLabel: 'Source & Priority',
|
||||
subtitle: 'Define how book information is collected from external services and prioritized across your libraries.',
|
||||
titleLabel: 'Providers',
|
||||
subtitle: 'Enable external metadata sources and configure their credentials.',
|
||||
},
|
||||
'field-rules': {
|
||||
navLabel: 'Field Rules',
|
||||
titleLabel: 'Field-Level Rules',
|
||||
subtitle: 'Control which provider supplies each metadata field and how values are merged across your libraries.',
|
||||
},
|
||||
score: {
|
||||
navLabel: 'Score',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, watch } from 'vue'
|
||||
import type { FieldPreference, MetadataField } from '@projectx/types'
|
||||
import { Info } from 'lucide-vue-next'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import GlobalPreferencePanel from './components/GlobalPreferencePanel.vue'
|
||||
import LibraryPreferencePanel from './components/LibraryPreferencePanel.vue'
|
||||
import { useProviderConfig } from './composables/useProviderConfig'
|
||||
import { useMetadataPreferences } from './composables/useMetadataPreferences'
|
||||
|
||||
const { statuses, fetchConfig } = useProviderConfig()
|
||||
const { globalPrefs, libraryPrefs, savingGlobal, savingField, fetchGlobal, saveGlobal, fetchLibrary, saveFieldOverride, resetLibrary } =
|
||||
useMetadataPreferences()
|
||||
const { libraries, fetchLibraries } = useLibraries()
|
||||
|
||||
onMounted(() => {
|
||||
void Promise.all([fetchConfig(), fetchGlobal(), fetchLibraries()]).catch(() => undefined)
|
||||
})
|
||||
|
||||
const fetchedLibraryIds = new Set<number>()
|
||||
|
||||
watch(libraries, async (libs) => {
|
||||
const newLibs = libs.filter((lib) => !fetchedLibraryIds.has(lib.id))
|
||||
newLibs.forEach((lib) => fetchedLibraryIds.add(lib.id))
|
||||
await Promise.all(newLibs.map((lib) => fetchLibrary(lib.id)))
|
||||
})
|
||||
|
||||
async function onFieldChange(libraryId: number, field: MetadataField, pref: FieldPreference | null) {
|
||||
await saveFieldOverride(libraryId, field, pref)
|
||||
}
|
||||
|
||||
async function onResetLibrary(libraryId: number) {
|
||||
await resetLibrary(libraryId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="p-4 rounded-xl bg-primary/5 border border-primary/10 max-w-5xl">
|
||||
<div class="flex items-start gap-3 mb-3">
|
||||
<Info :size="18" class="text-primary shrink-0 mt-0.5" />
|
||||
<p class="text-sm font-medium text-foreground">How Field Rules Work</p>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 gap-x-8 gap-y-4 text-xs leading-relaxed text-muted-foreground ml-7">
|
||||
<div>
|
||||
<p class="font-semibold text-foreground mb-1">Priority Order</p>
|
||||
<p>Drag providers in the list to reorder them. The top-most provider that returns a result will be the primary source for that field.</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-foreground mb-1">Merge Strategy</p>
|
||||
<ul class="space-y-1">
|
||||
<li><span class="text-foreground">Fill missing:</span> Only write if current value is empty.</li>
|
||||
<li><span class="text-foreground">Overwrite:</span> Replace whenever a provider has data.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GlobalPreferencePanel :preferences="globalPrefs" :statuses="statuses" :saving="savingGlobal" @save="saveGlobal" />
|
||||
|
||||
<div v-if="libraries.length" class="space-y-4">
|
||||
<div class="px-1">
|
||||
<p class="text-sm font-medium text-foreground">Library Overrides</p>
|
||||
<p class="settings-hint">Expand a library to customize individual fields. Fields not overridden inherit global defaults.</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<LibraryPreferencePanel
|
||||
v-for="lib in libraries"
|
||||
:key="lib.id"
|
||||
:library-name="lib.name"
|
||||
:library-prefs="libraryPrefs.get(lib.id) ?? null"
|
||||
:statuses="statuses"
|
||||
:saving-field="savingField"
|
||||
@field-change="onFieldChange"
|
||||
@reset="onResetLibrary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,110 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
import type { FieldPreference, MetadataField, ProviderConfigurations } from '@projectx/types'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import type { ProviderConfigurations } from '@projectx/types'
|
||||
import ProviderConfigPanel from './components/ProviderConfigPanel.vue'
|
||||
import GlobalPreferencePanel from './components/GlobalPreferencePanel.vue'
|
||||
import LibraryPreferencePanel from './components/LibraryPreferencePanel.vue'
|
||||
import { useProviderConfig } from './composables/useProviderConfig'
|
||||
import { useProviderThrottleRuntime } from './composables/useProviderThrottleRuntime'
|
||||
import { useMetadataPreferences } from './composables/useMetadataPreferences'
|
||||
import { Info } from 'lucide-vue-next'
|
||||
|
||||
const { config, statuses, saving: savingProviders, fetchConfig, saveConfig } = useProviderConfig()
|
||||
const { config, statuses, saving, fetchConfig, saveConfig } = useProviderConfig()
|
||||
const { runtimeByKey, startPolling, stopPolling } = useProviderThrottleRuntime()
|
||||
const { globalPrefs, libraryPrefs, savingGlobal, savingField, fetchGlobal, saveGlobal, fetchLibrary, saveFieldOverride, resetLibrary } =
|
||||
useMetadataPreferences()
|
||||
const { libraries, fetchLibraries } = useLibraries()
|
||||
|
||||
onMounted(() => {
|
||||
startPolling()
|
||||
void Promise.all([fetchConfig(), fetchGlobal(), fetchLibraries()]).catch(() => undefined)
|
||||
void fetchConfig().catch(() => undefined)
|
||||
})
|
||||
|
||||
const fetchedLibraryIds = new Set<number>()
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
watch(libraries, async (libs) => {
|
||||
const newLibs = libs.filter((lib) => !fetchedLibraryIds.has(lib.id))
|
||||
newLibs.forEach((lib) => fetchedLibraryIds.add(lib.id))
|
||||
await Promise.all(newLibs.map((lib) => fetchLibrary(lib.id)))
|
||||
})
|
||||
|
||||
async function onFieldChange(libraryId: number, field: MetadataField, pref: FieldPreference | null) {
|
||||
await saveFieldOverride(libraryId, field, pref)
|
||||
}
|
||||
|
||||
async function onResetLibrary(libraryId: number) {
|
||||
await resetLibrary(libraryId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<!-- Provider Configuration -->
|
||||
<section class="space-y-4">
|
||||
<p class="settings-group-label">External Connections</p>
|
||||
<ProviderConfigPanel
|
||||
:config="config"
|
||||
:statuses="statuses"
|
||||
:runtime-by-key="runtimeByKey"
|
||||
:saving="savingProviders"
|
||||
@save="saveConfig($event as Partial<ProviderConfigurations>)"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Field Preferences -->
|
||||
<section class="space-y-6">
|
||||
<div class="space-y-3">
|
||||
<p class="settings-group-label">Field-Level Rules</p>
|
||||
<div class="p-4 rounded-xl bg-primary/5 border border-primary/10 max-w-5xl">
|
||||
<div class="flex items-start gap-3 mb-3">
|
||||
<Info :size="18" class="text-primary shrink-0 mt-0.5" />
|
||||
<p class="text-sm font-medium text-foreground">How Field Rules Work</p>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 gap-x-8 gap-y-4 text-xs leading-relaxed text-muted-foreground ml-7">
|
||||
<div>
|
||||
<p class="font-semibold text-foreground mb-1">Priority Order</p>
|
||||
<p>
|
||||
Drag providers in the list to reorder them. The top-most provider that returns a result will be the primary source for that field.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-foreground mb-1">Merge Strategy</p>
|
||||
<ul class="space-y-1">
|
||||
<li><span class="text-foreground">Fill missing:</span> Only write if current value is empty.</li>
|
||||
<li><span class="text-foreground">Overwrite:</span> Replace whenever a provider has data.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<GlobalPreferencePanel :preferences="globalPrefs" :statuses="statuses" :saving="savingGlobal" @save="saveGlobal" />
|
||||
|
||||
<div v-if="libraries.length" class="space-y-4">
|
||||
<div class="px-1">
|
||||
<p class="text-sm font-medium text-foreground">Library Overrides</p>
|
||||
<p class="settings-hint">Expand a library to customize individual fields. Fields not overridden inherit global defaults.</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<LibraryPreferencePanel
|
||||
v-for="lib in libraries"
|
||||
:key="lib.id"
|
||||
:library-name="lib.name"
|
||||
:library-prefs="libraryPrefs.get(lib.id) ?? null"
|
||||
:statuses="statuses"
|
||||
:saving-field="savingField"
|
||||
@field-change="onFieldChange"
|
||||
@reset="onResetLibrary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<ProviderConfigPanel
|
||||
:config="config"
|
||||
:statuses="statuses"
|
||||
:runtime-by-key="runtimeByKey"
|
||||
:saving="saving"
|
||||
@save="saveConfig($event as Partial<ProviderConfigurations>)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -2,13 +2,13 @@ import type { MetadataProviderKey } from '@projectx/types'
|
||||
|
||||
const PROVIDER_HEX: Record<string, string> = {
|
||||
google: '#34A853',
|
||||
goodreads: '#06B6D4',
|
||||
goodreads: '#00d8d1',
|
||||
amazon: '#FF9900',
|
||||
hardcover: '#4F47E5',
|
||||
openLibrary: '#E83B2A',
|
||||
itunes: '#E252E9',
|
||||
hardcover: '#7772ff',
|
||||
openLibrary: '#49a4ff',
|
||||
itunes: '#ff4f5d',
|
||||
audible: '#FF8A00',
|
||||
audnexus: '#A78BFA',
|
||||
audnexus: '#FF5ADD',
|
||||
auto: '#8B5CF6',
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { OpenLibraryProvider } from './providers/open-library/open-library.provi
|
||||
import { ITunesProvider } from './providers/itunes/itunes.provider';
|
||||
import { AudibleProvider } from './providers/audible/audible.provider';
|
||||
import { AudnexusProvider } from './providers/audnexus/audnexus.provider';
|
||||
import { HardcoverProvider } from './providers/hardcover/hardcover.provider';
|
||||
|
||||
describe('MetadataFetchModule', () => {
|
||||
it('registers provider classes and factory wiring for METADATA_PROVIDERS', () => {
|
||||
@@ -28,6 +29,7 @@ describe('MetadataFetchModule', () => {
|
||||
ITunesProvider,
|
||||
AudibleProvider,
|
||||
AudnexusProvider,
|
||||
HardcoverProvider,
|
||||
ProviderRegistry,
|
||||
MetadataFetchService,
|
||||
MetadataFetchPipeline,
|
||||
@@ -48,6 +50,7 @@ describe('MetadataFetchModule', () => {
|
||||
ITunesProvider,
|
||||
AudibleProvider,
|
||||
AudnexusProvider,
|
||||
HardcoverProvider,
|
||||
]);
|
||||
|
||||
const google = { key: 'google' };
|
||||
|
||||
@@ -15,8 +15,19 @@ import { OpenLibraryProvider } from './providers/open-library/open-library.provi
|
||||
import { ITunesProvider } from './providers/itunes/itunes.provider';
|
||||
import { AudibleProvider } from './providers/audible/audible.provider';
|
||||
import { AudnexusProvider } from './providers/audnexus/audnexus.provider';
|
||||
import { HardcoverClient } from './providers/hardcover/hardcover.client';
|
||||
import { HardcoverProvider } from './providers/hardcover/hardcover.provider';
|
||||
|
||||
const PROVIDER_CLASSES = [GoogleProvider, GoodreadsProvider, AmazonProvider, OpenLibraryProvider, ITunesProvider, AudibleProvider, AudnexusProvider];
|
||||
const PROVIDER_CLASSES = [
|
||||
GoogleProvider,
|
||||
GoodreadsProvider,
|
||||
AmazonProvider,
|
||||
OpenLibraryProvider,
|
||||
ITunesProvider,
|
||||
AudibleProvider,
|
||||
AudnexusProvider,
|
||||
HardcoverProvider,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [MetadataPreferencesModule],
|
||||
@@ -27,6 +38,7 @@ const PROVIDER_CLASSES = [GoogleProvider, GoodreadsProvider, AmazonProvider, Ope
|
||||
useFactory: (...providers: MetadataProvider[]) => providers,
|
||||
inject: PROVIDER_CLASSES,
|
||||
},
|
||||
HardcoverClient,
|
||||
ProviderRegistry,
|
||||
ProviderThrottleTracker,
|
||||
MetadataFetchService,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { fetchWithThrottle } from '../../fetch-with-throttle';
|
||||
import { ProviderThrottleError } from '../../provider-throttle.error';
|
||||
import { HardcoverBookWithEditions, HardcoverBooksResponse, HardcoverSearchDocument, HardcoverSearchResponse } from './hardcover.types';
|
||||
|
||||
const GRAPHQL_ENDPOINT = 'https://api.hardcover.app/v1/graphql';
|
||||
const RATE_LIMIT_DELAY_MS = 1000;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_SEARCH_LIMIT = 10;
|
||||
|
||||
const BOOK_FIELDS = `
|
||||
id
|
||||
slug
|
||||
title
|
||||
subtitle
|
||||
description
|
||||
cached_contributors
|
||||
featured_book_series { series { name books_count } position }
|
||||
rating
|
||||
ratings_count
|
||||
pages
|
||||
release_date
|
||||
release_year
|
||||
image { url }
|
||||
`;
|
||||
|
||||
const EDITION_FIELDS = `
|
||||
id
|
||||
title
|
||||
subtitle
|
||||
cached_contributors
|
||||
pages
|
||||
release_date
|
||||
release_year
|
||||
image { url }
|
||||
publisher { name }
|
||||
isbn_10
|
||||
isbn_13
|
||||
language { code2 }
|
||||
`;
|
||||
|
||||
const SEARCH_BY_ISBN_QUERY = `
|
||||
query BookSearchByIsbn($isbn: String!) {
|
||||
books(where: { editions: { _or: [{ isbn_13: { _eq: $isbn } }, { isbn_10: { _eq: $isbn } }] } }) {
|
||||
${BOOK_FIELDS}
|
||||
editions(where: { _or: [{ isbn_13: { _eq: $isbn } }, { isbn_10: { _eq: $isbn } }] }) {
|
||||
${EDITION_FIELDS}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const SEARCH_BOOKS_QUERY = `
|
||||
query BookSearch($q: String!, $limit: Int!) {
|
||||
search(query: $q, query_type: "Book", per_page: $limit, page: 1) {
|
||||
results
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const LOOKUP_BY_SLUG_QUERY = `
|
||||
query BookBySlug($slug: String!) {
|
||||
books(where: { slug: { _eq: $slug } }) {
|
||||
${BOOK_FIELDS}
|
||||
editions(limit: 1) {
|
||||
${EDITION_FIELDS}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
class RateLimiter {
|
||||
private nextAllowedTime = 0;
|
||||
|
||||
async throttle(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const scheduled = Math.max(now, this.nextAllowedTime);
|
||||
this.nextAllowedTime = scheduled + RATE_LIMIT_DELAY_MS;
|
||||
const wait = scheduled - now;
|
||||
if (wait > 0) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, wait));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HardcoverClient {
|
||||
private readonly logger = new Logger(HardcoverClient.name);
|
||||
private readonly rateLimiter = new RateLimiter();
|
||||
|
||||
async searchByIsbn(isbn: string, apiKey: string): Promise<HardcoverBookWithEditions[]> {
|
||||
const body = await this.post<HardcoverBooksResponse>(SEARCH_BY_ISBN_QUERY, { isbn }, apiKey);
|
||||
return body?.data?.books ?? [];
|
||||
}
|
||||
|
||||
async searchBooks(query: string, apiKey: string): Promise<HardcoverSearchDocument[]> {
|
||||
const body = await this.post<HardcoverSearchResponse>(SEARCH_BOOKS_QUERY, { q: query, limit: DEFAULT_SEARCH_LIMIT }, apiKey);
|
||||
return body?.data?.search?.results?.hits?.map((h) => h.document).filter((d): d is HardcoverSearchDocument => d != null) ?? [];
|
||||
}
|
||||
|
||||
async lookupBySlug(slug: string, apiKey: string): Promise<HardcoverBookWithEditions | null> {
|
||||
const body = await this.post<HardcoverBooksResponse>(LOOKUP_BY_SLUG_QUERY, { slug }, apiKey);
|
||||
return body?.data?.books?.[0] ?? null;
|
||||
}
|
||||
|
||||
private async post<T>(query: string, variables: Record<string, unknown>, apiKey: string): Promise<T | null> {
|
||||
await this.rateLimiter.throttle();
|
||||
|
||||
try {
|
||||
const res = await fetchWithThrottle(GRAPHQL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`Hardcover API returned ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
} catch (err) {
|
||||
if (err instanceof ProviderThrottleError) {
|
||||
throw err;
|
||||
}
|
||||
this.logger.warn(`Hardcover API request failed: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { MetadataProviderKey } from '@projectx/types';
|
||||
|
||||
import { mapBookWithEditions, mapSearchDocument } from './hardcover.mapper';
|
||||
import { HardcoverBookWithEditions, HardcoverSearchDocument } from './hardcover.types';
|
||||
|
||||
const baseDocument: HardcoverSearchDocument = {
|
||||
id: '379217',
|
||||
slug: 'the-name-of-the-wind',
|
||||
title: 'The Name of the Wind',
|
||||
subtitle: '10th Anniversary Edition',
|
||||
description: 'A story about a wizard.',
|
||||
author_names: ['Patrick Rothfuss'],
|
||||
isbns: ['9780756404079', '0756404079'],
|
||||
pages: 662,
|
||||
release_year: 2007,
|
||||
release_date: '2007-03-27',
|
||||
genres: ['Fantasy', 'Fiction'],
|
||||
featured_series: { series: { name: 'The Kingkiller Chronicle' }, position: 1 },
|
||||
image: { url: 'https://assets.hardcover.app/cover.jpg' },
|
||||
};
|
||||
|
||||
const baseBook: HardcoverBookWithEditions = {
|
||||
id: 379217,
|
||||
slug: 'the-name-of-the-wind',
|
||||
title: 'The Name of the Wind',
|
||||
subtitle: 'Book Subtitle',
|
||||
description: 'A story about a wizard.',
|
||||
cached_contributors: [{ author: { id: 1, name: 'Patrick Rothfuss' }, contribution: null }],
|
||||
featured_book_series: { series: { name: 'The Kingkiller Chronicle', books_count: 3 }, position: 1 },
|
||||
pages: 662,
|
||||
release_year: 2007,
|
||||
release_date: '2007-03-27',
|
||||
image: { url: 'https://assets.hardcover.app/book-cover.jpg' },
|
||||
editions: [
|
||||
{
|
||||
id: 1001,
|
||||
title: 'The Name of the Wind',
|
||||
subtitle: 'Edition Subtitle',
|
||||
cached_contributors: [{ author: { id: 1, name: 'Patrick Rothfuss' }, contribution: null }],
|
||||
pages: 662,
|
||||
release_year: 2007,
|
||||
release_date: '2007-03-27',
|
||||
image: { url: 'https://assets.hardcover.app/edition-cover.jpg' },
|
||||
publisher: { name: 'DAW Books' },
|
||||
isbn_10: '0756404079',
|
||||
isbn_13: '9780756404079',
|
||||
language: { code2: 'en' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('mapSearchDocument', () => {
|
||||
it('maps a complete document correctly', () => {
|
||||
const result = mapSearchDocument(baseDocument);
|
||||
|
||||
expect(result).toEqual({
|
||||
provider: MetadataProviderKey.HARDCOVER,
|
||||
providerId: 'the-name-of-the-wind',
|
||||
title: 'The Name of the Wind',
|
||||
subtitle: '10th Anniversary Edition',
|
||||
description: 'A story about a wizard.',
|
||||
authors: ['Patrick Rothfuss'],
|
||||
pageCount: 662,
|
||||
publishedYear: 2007,
|
||||
isbn13: '9780756404079',
|
||||
isbn10: '0756404079',
|
||||
genres: ['Fantasy', 'Fiction'],
|
||||
seriesName: 'The Kingkiller Chronicle',
|
||||
seriesIndex: 1,
|
||||
coverUrl: 'https://assets.hardcover.app/cover.jpg',
|
||||
sourceUrl: 'https://hardcover.app/books/the-name-of-the-wind',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers release_year over release_date for publishedYear', () => {
|
||||
const doc: HardcoverSearchDocument = { ...baseDocument, release_year: 2007, release_date: '2009-01-01' };
|
||||
expect(mapSearchDocument(doc).publishedYear).toBe(2007);
|
||||
});
|
||||
|
||||
it('falls back to release_date when release_year is absent', () => {
|
||||
const doc: HardcoverSearchDocument = { ...baseDocument, release_year: undefined, release_date: '2007-03-27' };
|
||||
expect(mapSearchDocument(doc).publishedYear).toBe(2007);
|
||||
});
|
||||
|
||||
it('returns undefined publishedYear when both date fields are absent', () => {
|
||||
const doc: HardcoverSearchDocument = { ...baseDocument, release_year: undefined, release_date: undefined };
|
||||
expect(mapSearchDocument(doc).publishedYear).toBeUndefined();
|
||||
});
|
||||
|
||||
it('picks isbn13 and isbn10 from the isbns array', () => {
|
||||
const doc: HardcoverSearchDocument = { ...baseDocument, isbns: ['9780756404079', '0756404079', '9990000000000'] };
|
||||
const result = mapSearchDocument(doc);
|
||||
expect(result.isbn13).toBe('9780756404079');
|
||||
expect(result.isbn10).toBe('0756404079');
|
||||
});
|
||||
|
||||
it('returns undefined isbns when the array is absent', () => {
|
||||
const doc: HardcoverSearchDocument = { ...baseDocument, isbns: undefined };
|
||||
const result = mapSearchDocument(doc);
|
||||
expect(result.isbn10).toBeUndefined();
|
||||
expect(result.isbn13).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalises null series position to undefined', () => {
|
||||
const doc: HardcoverSearchDocument = {
|
||||
...baseDocument,
|
||||
featured_series: { series: { name: 'Some Series' }, position: null },
|
||||
};
|
||||
expect(mapSearchDocument(doc).seriesIndex).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined seriesName and seriesIndex when series is absent', () => {
|
||||
const doc: HardcoverSearchDocument = { ...baseDocument, featured_series: undefined };
|
||||
const result = mapSearchDocument(doc);
|
||||
expect(result.seriesName).toBeUndefined();
|
||||
expect(result.seriesIndex).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns empty authors array when author_names is absent', () => {
|
||||
const doc: HardcoverSearchDocument = { ...baseDocument, author_names: undefined };
|
||||
expect(mapSearchDocument(doc).authors).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns undefined coverUrl when image is absent', () => {
|
||||
const doc: HardcoverSearchDocument = { ...baseDocument, image: undefined };
|
||||
expect(mapSearchDocument(doc).coverUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapBookWithEditions', () => {
|
||||
it('maps a complete book+edition correctly', () => {
|
||||
const [result] = mapBookWithEditions(baseBook);
|
||||
|
||||
expect(result).toEqual({
|
||||
provider: MetadataProviderKey.HARDCOVER,
|
||||
providerId: 'the-name-of-the-wind',
|
||||
title: 'The Name of the Wind',
|
||||
subtitle: 'Edition Subtitle',
|
||||
description: 'A story about a wizard.',
|
||||
authors: ['Patrick Rothfuss'],
|
||||
publisher: 'DAW Books',
|
||||
language: 'en',
|
||||
pageCount: 662,
|
||||
publishedYear: 2007,
|
||||
isbn10: '0756404079',
|
||||
isbn13: '9780756404079',
|
||||
seriesName: 'The Kingkiller Chronicle',
|
||||
seriesIndex: 1,
|
||||
coverUrl: 'https://assets.hardcover.app/edition-cover.jpg',
|
||||
sourceUrl: 'https://hardcover.app/books/the-name-of-the-wind',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when editions is absent', () => {
|
||||
const book: HardcoverBookWithEditions = { ...baseBook, editions: undefined };
|
||||
expect(mapBookWithEditions(book)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when editions is empty', () => {
|
||||
const book: HardcoverBookWithEditions = { ...baseBook, editions: [] };
|
||||
expect(mapBookWithEditions(book)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns one candidate per edition', () => {
|
||||
const book: HardcoverBookWithEditions = {
|
||||
...baseBook,
|
||||
editions: [
|
||||
{ ...baseBook.editions![0], id: 1001, isbn_13: '9780000000001' },
|
||||
{ ...baseBook.editions![0], id: 1002, isbn_13: '9780000000002' },
|
||||
],
|
||||
};
|
||||
const results = mapBookWithEditions(book);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].isbn13).toBe('9780000000001');
|
||||
expect(results[1].isbn13).toBe('9780000000002');
|
||||
});
|
||||
|
||||
it('uses edition authors when present', () => {
|
||||
const book: HardcoverBookWithEditions = {
|
||||
...baseBook,
|
||||
cached_contributors: [{ author: { id: 2, name: 'Book-Level Author' }, contribution: null }],
|
||||
editions: [
|
||||
{
|
||||
...baseBook.editions![0],
|
||||
cached_contributors: [{ author: { id: 1, name: 'Edition Author' }, contribution: null }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(mapBookWithEditions(book)[0].authors).toEqual(['Edition Author']);
|
||||
});
|
||||
|
||||
it('falls back to book authors when edition has no contributors', () => {
|
||||
const book: HardcoverBookWithEditions = {
|
||||
...baseBook,
|
||||
cached_contributors: [{ author: { id: 2, name: 'Book Author' }, contribution: null }],
|
||||
editions: [{ ...baseBook.editions![0], cached_contributors: [] }],
|
||||
};
|
||||
expect(mapBookWithEditions(book)[0].authors).toEqual(['Book Author']);
|
||||
});
|
||||
|
||||
it('falls back to book title when edition title is absent', () => {
|
||||
const book: HardcoverBookWithEditions = {
|
||||
...baseBook,
|
||||
editions: [{ ...baseBook.editions![0], title: undefined }],
|
||||
};
|
||||
expect(mapBookWithEditions(book)[0].title).toBe('The Name of the Wind');
|
||||
});
|
||||
|
||||
it('falls back to book subtitle when edition subtitle is absent', () => {
|
||||
const book: HardcoverBookWithEditions = {
|
||||
...baseBook,
|
||||
editions: [{ ...baseBook.editions![0], subtitle: undefined }],
|
||||
};
|
||||
expect(mapBookWithEditions(book)[0].subtitle).toBe('Book Subtitle');
|
||||
});
|
||||
|
||||
it('falls back to book image when edition image is absent', () => {
|
||||
const book: HardcoverBookWithEditions = {
|
||||
...baseBook,
|
||||
editions: [{ ...baseBook.editions![0], image: undefined }],
|
||||
};
|
||||
expect(mapBookWithEditions(book)[0].coverUrl).toBe('https://assets.hardcover.app/book-cover.jpg');
|
||||
});
|
||||
|
||||
it('falls back to book publishedYear when edition has no date fields', () => {
|
||||
const book: HardcoverBookWithEditions = {
|
||||
...baseBook,
|
||||
release_year: 2007,
|
||||
release_date: undefined,
|
||||
editions: [{ ...baseBook.editions![0], release_year: undefined, release_date: undefined }],
|
||||
};
|
||||
expect(mapBookWithEditions(book)[0].publishedYear).toBe(2007);
|
||||
});
|
||||
|
||||
it('normalises null series position to undefined', () => {
|
||||
const book: HardcoverBookWithEditions = {
|
||||
...baseBook,
|
||||
featured_book_series: { series: { name: 'Some Series' }, position: null },
|
||||
};
|
||||
expect(mapBookWithEditions(book)[0].seriesIndex).toBeUndefined();
|
||||
});
|
||||
|
||||
it('filters out contributors with no author name', () => {
|
||||
const book: HardcoverBookWithEditions = {
|
||||
...baseBook,
|
||||
editions: [
|
||||
{
|
||||
...baseBook.editions![0],
|
||||
cached_contributors: [
|
||||
{ author: { id: 1, name: 'Valid Author' }, contribution: null },
|
||||
{ author: { id: 2, name: undefined }, contribution: null },
|
||||
{ contribution: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(mapBookWithEditions(book)[0].authors).toEqual(['Valid Author']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
|
||||
|
||||
import { HardcoverBookWithEditions, HardcoverCachedContributor, HardcoverEdition, HardcoverSearchDocument } from './hardcover.types';
|
||||
|
||||
function parseYear(releaseYear: number | undefined | null, releaseDate: string | undefined): number | undefined {
|
||||
if (releaseYear != null) return releaseYear;
|
||||
if (!releaseDate) return undefined;
|
||||
const year = parseInt(releaseDate.substring(0, 4), 10);
|
||||
return Number.isNaN(year) ? undefined : year;
|
||||
}
|
||||
|
||||
function extractAuthorsFromContributors(contributors: HardcoverCachedContributor[] | undefined): string[] {
|
||||
if (!contributors) return [];
|
||||
return contributors.map((c) => c.author?.name).filter((n): n is string => n != null);
|
||||
}
|
||||
|
||||
function pickIsbn(isbns: string[] | undefined): { isbn10?: string; isbn13?: string } {
|
||||
if (!isbns) return {};
|
||||
return {
|
||||
isbn13: isbns.find((i) => i.length === 13),
|
||||
isbn10: isbns.find((i) => i.length === 10),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSearchDocument(doc: HardcoverSearchDocument): MetadataCandidate {
|
||||
const { isbn10, isbn13 } = pickIsbn(doc.isbns);
|
||||
|
||||
return {
|
||||
provider: MetadataProviderKey.HARDCOVER,
|
||||
providerId: doc.slug,
|
||||
title: doc.title,
|
||||
subtitle: doc.subtitle,
|
||||
description: doc.description,
|
||||
authors: doc.author_names ?? [],
|
||||
pageCount: doc.pages,
|
||||
publishedYear: parseYear(doc.release_year, doc.release_date),
|
||||
isbn10,
|
||||
isbn13,
|
||||
genres: doc.genres,
|
||||
seriesName: doc.featured_series?.series?.name,
|
||||
seriesIndex: doc.featured_series?.position ?? undefined,
|
||||
coverUrl: doc.image?.url,
|
||||
sourceUrl: `https://hardcover.app/books/${doc.slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBookWithEditions(book: HardcoverBookWithEditions): MetadataCandidate[] {
|
||||
if (!book.editions || book.editions.length === 0) return [];
|
||||
return book.editions.map((edition) => mapEdition(edition, book));
|
||||
}
|
||||
|
||||
function mapEdition(edition: HardcoverEdition, book: HardcoverBookWithEditions): MetadataCandidate {
|
||||
const editionAuthors = extractAuthorsFromContributors(edition.cached_contributors);
|
||||
const authors = editionAuthors.length > 0 ? editionAuthors : extractAuthorsFromContributors(book.cached_contributors);
|
||||
|
||||
return {
|
||||
provider: MetadataProviderKey.HARDCOVER,
|
||||
providerId: book.slug,
|
||||
title: edition.title ?? book.title,
|
||||
subtitle: edition.subtitle ?? book.subtitle,
|
||||
description: book.description,
|
||||
authors,
|
||||
publisher: edition.publisher?.name,
|
||||
language: edition.language?.code2,
|
||||
pageCount: edition.pages ?? book.pages,
|
||||
publishedYear: parseYear(edition.release_year ?? book.release_year, edition.release_date ?? book.release_date),
|
||||
isbn10: edition.isbn_10,
|
||||
isbn13: edition.isbn_13,
|
||||
seriesName: book.featured_book_series?.series?.name,
|
||||
seriesIndex: book.featured_book_series?.position ?? undefined,
|
||||
coverUrl: edition.image?.url ?? book.image?.url,
|
||||
sourceUrl: `https://hardcover.app/books/${book.slug}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ProviderConfigurations } from '@projectx/types';
|
||||
|
||||
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
|
||||
import { HardcoverClient } from './hardcover.client';
|
||||
import { HardcoverProvider } from './hardcover.provider';
|
||||
import { HardcoverBookWithEditions, HardcoverSearchDocument } from './hardcover.types';
|
||||
|
||||
const mockConfig: ProviderConfigurations = {
|
||||
google: { enabled: true, apiKey: '' },
|
||||
amazon: { enabled: true, domain: 'amazon.com', cookie: '' },
|
||||
goodreads: { enabled: true },
|
||||
hardcover: { enabled: true, apiKey: 'test-key' },
|
||||
openLibrary: { enabled: true },
|
||||
itunes: { enabled: true },
|
||||
audible: { enabled: false, domain: 'com' },
|
||||
audnexus: { enabled: false },
|
||||
};
|
||||
|
||||
const mockDocument: HardcoverSearchDocument = {
|
||||
id: '379217',
|
||||
slug: 'the-name-of-the-wind',
|
||||
title: 'The Name of the Wind',
|
||||
author_names: ['Patrick Rothfuss'],
|
||||
release_year: 2007,
|
||||
image: { url: 'https://assets.hardcover.app/cover.jpg' },
|
||||
};
|
||||
|
||||
const mockBook: HardcoverBookWithEditions = {
|
||||
id: 379217,
|
||||
slug: 'the-name-of-the-wind',
|
||||
title: 'The Name of the Wind',
|
||||
editions: [
|
||||
{
|
||||
id: 1001,
|
||||
title: 'The Name of the Wind',
|
||||
isbn_13: '9780756404079',
|
||||
isbn_10: '0756404079',
|
||||
release_year: 2007,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('HardcoverProvider', () => {
|
||||
let provider: HardcoverProvider;
|
||||
let client: HardcoverClient;
|
||||
let providerConfig: ProviderConfigService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
HardcoverProvider,
|
||||
{
|
||||
provide: HardcoverClient,
|
||||
useValue: {
|
||||
searchByIsbn: vi.fn().mockResolvedValue([]),
|
||||
searchBooks: vi.fn().mockResolvedValue([]),
|
||||
lookupBySlug: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ProviderConfigService,
|
||||
useValue: {
|
||||
getConfig: vi.fn().mockResolvedValue(mockConfig),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
provider = module.get(HardcoverProvider);
|
||||
client = module.get(HardcoverClient);
|
||||
providerConfig = module.get(ProviderConfigService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('returns empty array when disabled', async () => {
|
||||
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({ ...mockConfig, hardcover: { enabled: false, apiKey: 'test-key' } });
|
||||
expect(await provider.search({ title: 'Test' })).toEqual([]);
|
||||
expect(client.searchByIsbn).not.toHaveBeenCalled();
|
||||
expect(client.searchBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty array when apiKey is missing', async () => {
|
||||
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({ ...mockConfig, hardcover: { enabled: true, apiKey: '' } });
|
||||
expect(await provider.search({ title: 'Test' })).toEqual([]);
|
||||
expect(client.searchByIsbn).not.toHaveBeenCalled();
|
||||
expect(client.searchBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty array when no isbn and no title', async () => {
|
||||
expect(await provider.search({})).toEqual([]);
|
||||
expect(client.searchByIsbn).not.toHaveBeenCalled();
|
||||
expect(client.searchBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('searches by ISBN and returns mapped editions when found', async () => {
|
||||
vi.spyOn(client, 'searchByIsbn').mockResolvedValue([mockBook]);
|
||||
|
||||
const results = await provider.search({ isbn: '9780756404079' });
|
||||
|
||||
expect(client.searchByIsbn).toHaveBeenCalledWith('9780756404079', 'test-key');
|
||||
expect(client.searchBooks).not.toHaveBeenCalled();
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].providerId).toBe('the-name-of-the-wind');
|
||||
expect(results[0].isbn13).toBe('9780756404079');
|
||||
});
|
||||
|
||||
it('falls through to title search when ISBN returns no results', async () => {
|
||||
vi.spyOn(client, 'searchByIsbn').mockResolvedValue([]);
|
||||
vi.spyOn(client, 'searchBooks').mockResolvedValue([mockDocument]);
|
||||
|
||||
const results = await provider.search({ isbn: '9780756404079', title: 'The Name of the Wind' });
|
||||
|
||||
expect(client.searchByIsbn).toHaveBeenCalledWith('9780756404079', 'test-key');
|
||||
expect(client.searchBooks).toHaveBeenCalled();
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('searches title+author combined when author is provided', async () => {
|
||||
vi.spyOn(client, 'searchBooks').mockResolvedValue([mockDocument]);
|
||||
|
||||
await provider.search({ title: 'The Name of the Wind', author: 'Patrick Rothfuss' });
|
||||
|
||||
expect(client.searchBooks).toHaveBeenCalledWith('The Name of the Wind Patrick Rothfuss', 'test-key');
|
||||
});
|
||||
|
||||
it('retries with title only when title+author returns no results', async () => {
|
||||
vi.spyOn(client, 'searchBooks').mockResolvedValueOnce([]).mockResolvedValueOnce([mockDocument]);
|
||||
|
||||
const results = await provider.search({ title: 'The Name of the Wind', author: 'Patrick Rothfuss' });
|
||||
|
||||
expect(client.searchBooks).toHaveBeenCalledTimes(2);
|
||||
expect(client.searchBooks).toHaveBeenNthCalledWith(1, 'The Name of the Wind Patrick Rothfuss', 'test-key');
|
||||
expect(client.searchBooks).toHaveBeenNthCalledWith(2, 'The Name of the Wind', 'test-key');
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('searches by title only when no author is provided', async () => {
|
||||
vi.spyOn(client, 'searchBooks').mockResolvedValue([mockDocument]);
|
||||
|
||||
await provider.search({ title: 'The Name of the Wind' });
|
||||
|
||||
expect(client.searchBooks).toHaveBeenCalledTimes(1);
|
||||
expect(client.searchBooks).toHaveBeenCalledWith('The Name of the Wind', 'test-key');
|
||||
});
|
||||
|
||||
it('returns empty array when title search returns nothing', async () => {
|
||||
vi.spyOn(client, 'searchBooks').mockResolvedValue([]);
|
||||
|
||||
expect(await provider.search({ title: 'Unknown Book' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes the apiKey to the client', async () => {
|
||||
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({ ...mockConfig, hardcover: { enabled: true, apiKey: 'my-secret-key' } });
|
||||
vi.spyOn(client, 'searchBooks').mockResolvedValue([mockDocument]);
|
||||
|
||||
await provider.search({ title: 'Test' });
|
||||
|
||||
expect(client.searchBooks).toHaveBeenCalledWith('Test', 'my-secret-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookupById', () => {
|
||||
it('returns null when disabled', async () => {
|
||||
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({ ...mockConfig, hardcover: { enabled: false, apiKey: 'test-key' } });
|
||||
expect(await provider.lookupById('the-name-of-the-wind')).toBeNull();
|
||||
expect(client.lookupBySlug).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when apiKey is missing', async () => {
|
||||
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({ ...mockConfig, hardcover: { enabled: true, apiKey: '' } });
|
||||
expect(await provider.lookupById('the-name-of-the-wind')).toBeNull();
|
||||
expect(client.lookupBySlug).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('looks up by slug and returns the first mapped candidate', async () => {
|
||||
vi.spyOn(client, 'lookupBySlug').mockResolvedValue(mockBook);
|
||||
|
||||
const result = await provider.lookupById('the-name-of-the-wind');
|
||||
|
||||
expect(client.lookupBySlug).toHaveBeenCalledWith('the-name-of-the-wind', 'test-key');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.providerId).toBe('the-name-of-the-wind');
|
||||
expect(result?.isbn13).toBe('9780756404079');
|
||||
});
|
||||
|
||||
it('returns null when the slug is not found', async () => {
|
||||
vi.spyOn(client, 'lookupBySlug').mockResolvedValue(null);
|
||||
expect(await provider.lookupById('nonexistent-book')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the book has no editions', async () => {
|
||||
vi.spyOn(client, 'lookupBySlug').mockResolvedValue({ ...mockBook, editions: [] });
|
||||
expect(await provider.lookupById('the-name-of-the-wind')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
|
||||
|
||||
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
|
||||
import { IdentifiableProvider } from '../metadata-provider';
|
||||
import { MetadataSearchParams } from '../metadata-search-params';
|
||||
import { HardcoverClient } from './hardcover.client';
|
||||
import { mapBookWithEditions, mapSearchDocument } from './hardcover.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class HardcoverProvider implements IdentifiableProvider {
|
||||
readonly key = MetadataProviderKey.HARDCOVER;
|
||||
readonly label = 'Hardcover';
|
||||
readonly identifiable = true as const;
|
||||
|
||||
private readonly logger = new Logger(HardcoverProvider.name);
|
||||
|
||||
constructor(
|
||||
private readonly client: HardcoverClient,
|
||||
private readonly providerConfig: ProviderConfigService,
|
||||
) {}
|
||||
|
||||
async search(params: MetadataSearchParams): Promise<MetadataCandidate[]> {
|
||||
const { enabled, apiKey } = await this.providerConfig.getConfig().then((c) => c.hardcover);
|
||||
if (!enabled || !apiKey) return [];
|
||||
|
||||
if (params.isbn) {
|
||||
const books = await this.client.searchByIsbn(params.isbn, apiKey);
|
||||
if (books.length > 0) {
|
||||
return books.flatMap(mapBookWithEditions);
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.title) return [];
|
||||
|
||||
if (params.author) {
|
||||
const docs = await this.client.searchBooks(`${params.title} ${params.author}`, apiKey);
|
||||
if (docs.length > 0) {
|
||||
return docs.map(mapSearchDocument);
|
||||
}
|
||||
this.logger.debug(`Hardcover: no results for title+author, retrying with title only`);
|
||||
}
|
||||
|
||||
const docs = await this.client.searchBooks(params.title, apiKey);
|
||||
return docs.map(mapSearchDocument);
|
||||
}
|
||||
|
||||
async lookupById(providerId: string): Promise<MetadataCandidate | null> {
|
||||
const { enabled, apiKey } = await this.providerConfig.getConfig().then((c) => c.hardcover);
|
||||
if (!enabled || !apiKey) return null;
|
||||
|
||||
const book = await this.client.lookupBySlug(providerId, apiKey);
|
||||
if (!book) return null;
|
||||
|
||||
return mapBookWithEditions(book)[0] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
export interface HardcoverSearchResponse {
|
||||
data?: {
|
||||
search?: {
|
||||
results?: HardcoverSearchResults;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface HardcoverSearchResults {
|
||||
hits?: HardcoverSearchHit[];
|
||||
found?: number;
|
||||
}
|
||||
|
||||
export interface HardcoverSearchHit {
|
||||
document?: HardcoverSearchDocument;
|
||||
}
|
||||
|
||||
export interface HardcoverSearchDocument {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
author_names?: string[];
|
||||
isbns?: string[];
|
||||
pages?: number;
|
||||
release_date?: string;
|
||||
release_year?: number;
|
||||
genres?: string[];
|
||||
featured_series?: {
|
||||
series?: {
|
||||
name?: string;
|
||||
};
|
||||
position?: number | null;
|
||||
};
|
||||
image?: HardcoverImage;
|
||||
}
|
||||
|
||||
export interface HardcoverBooksResponse {
|
||||
data?: {
|
||||
books?: HardcoverBookWithEditions[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface HardcoverBookWithEditions {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
cached_contributors?: HardcoverCachedContributor[];
|
||||
featured_book_series?: {
|
||||
series?: {
|
||||
name?: string;
|
||||
books_count?: number;
|
||||
};
|
||||
position?: number | null;
|
||||
};
|
||||
rating?: number;
|
||||
ratings_count?: number;
|
||||
pages?: number;
|
||||
release_date?: string;
|
||||
release_year?: number;
|
||||
image?: HardcoverImage;
|
||||
editions?: HardcoverEdition[];
|
||||
}
|
||||
|
||||
export interface HardcoverEdition {
|
||||
id: number;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
cached_contributors?: HardcoverCachedContributor[];
|
||||
pages?: number;
|
||||
release_date?: string;
|
||||
release_year?: number;
|
||||
image?: HardcoverImage;
|
||||
publisher?: { name: string };
|
||||
isbn_10?: string;
|
||||
isbn_13?: string;
|
||||
language?: { code2: string };
|
||||
}
|
||||
|
||||
export interface HardcoverCachedContributor {
|
||||
author?: {
|
||||
id?: number;
|
||||
name?: string;
|
||||
};
|
||||
contribution?: string | null;
|
||||
}
|
||||
|
||||
export interface HardcoverImage {
|
||||
url?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user