fix(metadata): populate Hardcover genres for identified books

Request cached tags for ISBN and stored-ID GraphQL lookups. Normalize the Genre bucket so identified books retain the same genre metadata as free-text search results.

Fixes #1422
This commit is contained in:
neon
2026-09-22 11:56:54 +00:00
parent 6fa4d68728
commit 5609b38901
6 changed files with 107 additions and 3 deletions
@@ -73,6 +73,26 @@ describe('HardcoverClient', () => {
);
});
it.each([
['ISBN search', () => client.searchByIsbn('9780756404079', apiKey)],
['stored ID lookup', () => client.lookupBySlug('the-name-of-the-wind', apiKey)],
])('requests cached tags for %s', async (_label, request) => {
const mockFetch = vi.mocked(fetchWithThrottleModule.fetchWithThrottle);
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ data: { books: [] } }),
} as Response);
await request();
const options = mockFetch.mock.calls[0][1];
expect(typeof options?.body).toBe('string');
if (typeof options?.body !== 'string') throw new TypeError('Expected a JSON request body');
const body = JSON.parse(options.body) as { query: string };
expect(body.query).toMatch(/\bcached_tags\b/);
});
it('returns empty array when API returns non-ok status', async () => {
const mockFetch = vi.mocked(fetchWithThrottleModule.fetchWithThrottle);
mockFetch.mockResolvedValue({
@@ -16,6 +16,7 @@ const BOOK_FIELDS = `
subtitle
description
cached_contributors
cached_tags
featured_book_series { series { name books_count } position }
rating
ratings_count
@@ -29,6 +29,12 @@ const baseBook: HardcoverBookWithEditions = {
subtitle: 'Book Subtitle',
description: 'A story about a wizard.',
cached_contributors: [{ author: { id: 1, name: 'Patrick Rothfuss' }, contribution: null }],
cached_tags: {
Genre: [
{ tag: 'Fantasy', count: 10 },
{ tag: 'Fiction', count: 8 },
],
},
featured_book_series: { series: { name: 'The Kingkiller Chronicle', books_count: 3 }, position: 1 },
rating: 4.42,
ratings_count: 12345,
@@ -289,6 +295,7 @@ describe('mapBookWithEditions', () => {
publishedYear: 2007,
isbn10: '0756404079',
isbn13: '9780756404079',
genres: ['Fantasy', 'Fiction'],
seriesName: 'The Kingkiller Chronicle',
seriesIndex: '1',
seriesTotalBooks: 3,
@@ -356,6 +363,42 @@ describe('mapBookWithEditions', () => {
expect(results[1].hardcoverEditionId).toBe('1002');
});
describe('genres', () => {
it('maps the Genre cached-tag bucket to every edition candidate', () => {
const book: HardcoverBookWithEditions = {
...baseBook,
editions: [
{ ...baseBook.editions![0], id: 1001 },
{ ...baseBook.editions![0], id: 1002 },
],
};
expect(mapBookWithEditions(book).map((candidate) => candidate.genres)).toEqual([
['Fantasy', 'Fiction'],
['Fantasy', 'Fiction'],
]);
});
it('trims, de-duplicates, and ignores malformed cached tags', () => {
const book: HardcoverBookWithEditions = {
...baseBook,
cached_tags: {
Genre: [null, 'Fantasy', {}, { tag: null }, { tag: 42 }, { tag: ' ' }, { tag: 'Fantasy' }, { tag: ' fantasy ' }, { tag: 'Sci-Fi' }],
},
};
expect(mapBookWithEditions(book)[0].genres).toEqual(['Fantasy', 'Sci-Fi']);
});
it.each([undefined, null, {}, { Genre: null }, { Genre: { tag: 'Fantasy' } }, { Genre: [] }])(
'omits genres for an absent or invalid Genre bucket',
(cachedTags) => {
const book: HardcoverBookWithEditions = { ...baseBook, cached_tags: cachedTags };
expect(mapBookWithEditions(book)[0].genres).toBeUndefined();
},
);
});
it('uses edition authors when present', () => {
const book: HardcoverBookWithEditions = {
...baseBook,
@@ -2,7 +2,13 @@ import { MetadataCandidate, MetadataProviderKey, parseSeriesIndex } from '@booko
import { parsePublishedDateKey, parsePublishedYear, publishedYearFromDateKey } from '../../../../common/utils/published-date.utils';
import { normalizeSeriesTotalBooks } from '../../../../common/utils/series-total-books.utils';
import { HardcoverBookWithEditions, HardcoverCachedContributor, HardcoverEdition, HardcoverSearchDocument } from './hardcover.types';
import {
HardcoverBookWithEditions,
HardcoverCachedContributor,
HardcoverCachedTags,
HardcoverEdition,
HardcoverSearchDocument,
} from './hardcover.types';
function parseYear(releaseYear: number | undefined | null, releaseDate: string | undefined): number | undefined {
return parsePublishedYear(releaseYear) ?? parsePublishedYear(releaseDate);
@@ -27,6 +33,22 @@ function extractAuthorsFromContributors(contributors: HardcoverCachedContributor
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0);
}
function extractGenresFromCachedTags(cachedTags: HardcoverCachedTags | null | undefined): string[] | undefined {
if (!Array.isArray(cachedTags?.Genre)) return undefined;
const genres: string[] = [];
const seen = new Set<string>();
for (const entry of cachedTags.Genre) {
if (typeof entry !== 'object' || entry === null || !('tag' in entry) || typeof entry.tag !== 'string') continue;
const genre = entry.tag.trim();
const token = genre.toLowerCase();
if (!genre || seen.has(token)) continue;
seen.add(token);
genres.push(genre);
}
return genres.length ? genres : undefined;
}
function pickIsbn(isbns: string[] | undefined): { isbn10?: string; isbn13?: string } {
if (!isbns) return {};
return {
@@ -125,6 +147,7 @@ function mapEdition(edition: HardcoverEdition, book: HardcoverBookWithEditions):
const authors = editionAuthors.length > 0 ? editionAuthors : extractAuthorsFromContributors(book.cached_contributors);
const communityRating = normalizeCommunityRating(book.rating);
const communityRatingCount = normalizeCommunityRatingCount(book.ratings_count);
const genres = extractGenresFromCachedTags(book.cached_tags);
const { title, subtitle } = splitEmbeddedSubtitle(edition.title ?? book.title, edition.subtitle ?? book.subtitle);
return {
@@ -142,6 +165,7 @@ function mapEdition(edition: HardcoverEdition, book: HardcoverBookWithEditions):
publishedYear: resolveEditionPublishedYear(edition, book),
isbn10: edition.isbn_10,
isbn13: edition.isbn_13,
...(genres ? { genres } : {}),
seriesName: book.featured_book_series?.series?.name,
seriesIndex: parseSeriesIndex(book.featured_book_series?.position) ?? undefined,
seriesTotalBooks: normalizeSeriesTotalBooks(book.featured_book_series?.series?.books_count),
@@ -102,7 +102,12 @@ describe('HardcoverProvider', () => {
});
it('searches by ISBN and returns mapped editions when found', async () => {
vi.spyOn(client, 'searchByIsbn').mockResolvedValue([mockBook]);
vi.spyOn(client, 'searchByIsbn').mockResolvedValue([
{
...mockBook,
cached_tags: { Genre: [{ tag: 'Fantasy' }, { tag: 'Fiction' }] },
},
]);
const results = await provider.search({ isbn: '9780756404079' });
@@ -111,6 +116,7 @@ describe('HardcoverProvider', () => {
expect(results).toHaveLength(1);
expect(results[0].providerId).toBe('the-name-of-the-wind');
expect(results[0].isbn13).toBe('9780756404079');
expect(results[0].genres).toEqual(['Fantasy', 'Fiction']);
});
it('orders physical editions before audiobooks in ISBN search results', async () => {
@@ -236,7 +242,10 @@ describe('HardcoverProvider', () => {
});
it('looks up by slug and returns the first mapped candidate', async () => {
vi.spyOn(client, 'lookupBySlug').mockResolvedValue(mockBook);
vi.spyOn(client, 'lookupBySlug').mockResolvedValue({
...mockBook,
cached_tags: { Genre: [{ tag: 'Fantasy' }, { tag: 'Fiction' }] },
});
const result = await provider.lookupById('the-name-of-the-wind');
@@ -244,6 +253,7 @@ describe('HardcoverProvider', () => {
expect(result).not.toBeNull();
expect(result?.providerId).toBe('the-name-of-the-wind');
expect(result?.isbn13).toBe('9780756404079');
expect(result?.genres).toEqual(['Fantasy', 'Fiction']);
});
it('returns the physical edition even when an audiobook is listed first', async () => {
@@ -52,6 +52,7 @@ export interface HardcoverBookWithEditions {
subtitle?: string;
description?: string;
cached_contributors?: HardcoverCachedContributor[];
cached_tags?: HardcoverCachedTags | null;
featured_book_series?: {
series?: {
name?: string;
@@ -68,6 +69,11 @@ export interface HardcoverBookWithEditions {
editions?: HardcoverEdition[];
}
export interface HardcoverCachedTags {
Genre?: unknown;
[category: string]: unknown;
}
export interface HardcoverEdition {
id: number;
title?: string;