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:
neon
2026-03-23 07:24:32 -06:00
parent eb5d08ad24
commit 50fa9f6627
14 changed files with 945 additions and 104 deletions
@@ -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>
+5 -5
View File
@@ -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;
}