mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user