feat(metadata): expand book request discovery

Allow request searches to browse broader provider result sets, rerun queries by exact author, and submit unmatched titles for approval. Advertise the optional limit so older clients retain their existing contract.

Closes #1363
This commit is contained in:
neon
2026-10-20 18:27:02 +00:00
parent 2be79af880
commit 0d695a7ea0
25 changed files with 517 additions and 75 deletions
@@ -96,6 +96,19 @@ describe('MetadataSearchDto', () => {
expect(falseValue.dto.isAudiobook).toBe(false);
});
it('converts resultsPerProvider to a number within the browsing cap', async () => {
const valid = await validateInput({ author: 'Terry Pratchett', resultsPerProvider: '20' });
const tooMany = await validateInput({ author: 'Terry Pratchett', resultsPerProvider: '21' });
const zero = await validateInput({ author: 'Terry Pratchett', resultsPerProvider: '0' });
const fraction = await validateInput({ author: 'Terry Pratchett', resultsPerProvider: '2.5' });
expect(valid.errors).toHaveLength(0);
expect(valid.dto.resultsPerProvider).toBe(20);
for (const { errors } of [tooMany, zero, fraction]) {
expect(errors).toEqual(expect.arrayContaining([expect.objectContaining({ property: 'resultsPerProvider' })]));
}
});
it('rejects unknown provider keys after normalization', async () => {
const { errors } = await validateInput({
title: 'Dune',
@@ -6,6 +6,7 @@ import {
IsEnum,
IsIn,
IsInt,
Max,
Min,
IsOptional,
IsString,
@@ -17,7 +18,12 @@ import {
ValidatorConstraintInterface,
} from 'class-validator';
import { CONCRETE_BOOK_MEDIA_KINDS, METADATA_CACHE_REQUEST_POLICIES, MetadataProviderKey } from '@bookorbit/types';
import {
CONCRETE_BOOK_MEDIA_KINDS,
METADATA_CACHE_REQUEST_POLICIES,
METADATA_SEARCH_MAX_RESULTS_PER_PROVIDER,
MetadataProviderKey,
} from '@bookorbit/types';
import type { ConcreteBookMediaKind, MetadataCacheRequestPolicy } from '@bookorbit/types';
@ValidatorConstraint({ name: 'atLeastOneSearchTerm', async: false })
@@ -131,4 +137,16 @@ export class MetadataSearchDto {
@IsOptional()
@IsIn(METADATA_CACHE_REQUEST_POLICIES)
cache?: MetadataCacheRequestPolicy;
/**
* How many ranked candidates each provider may contribute, for a search that browses rather than
* matches one book. Absent keeps the matching default. Clients must only send it to servers
* advertising the `metadata-search-results-per-provider` feature.
*/
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(METADATA_SEARCH_MAX_RESULTS_PER_PROVIDER)
resultsPerProvider?: number;
}
@@ -341,6 +341,16 @@ describe('MetadataFetchController', () => {
expect(service.search).toHaveBeenCalledWith(expect.anything(), expect.anything(), { directive: { policy: 'refresh', allowFallback: true } });
});
it('passes a browsing search its per-provider result count, and leaves a match search on the default', async () => {
service.search.mockReturnValue(of());
await firstValueFrom((await controller.stream({ author: 'Terry Pratchett', resultsPerProvider: 20 }, user)).pipe(toArray()));
await firstValueFrom((await controller.stream({ title: 'Dune' }, user)).pipe(toArray()));
expect(service.search).toHaveBeenNthCalledWith(1, expect.objectContaining({ resultsPerProvider: 20 }), expect.anything(), expect.anything());
expect(service.search.mock.calls[1][0].resultsPerProvider).toBeUndefined();
});
it('does not treat the prefilled book title as a query the user typed', async () => {
service.getStoredProviderContext.mockResolvedValue({
libraryId: 5,
@@ -122,6 +122,7 @@ export class MetadataFetchController {
isAudiobook,
includeAudiobookProviders: isAudiobook || providerKeys.some(servesOnlyAudiobooks),
validateCoverPlaceholders: true,
resultsPerProvider: dto.resultsPerProvider,
};
const genreOptions = preferences.options?.genres;
@@ -303,6 +303,27 @@ describe('MetadataFetchService', () => {
expect(audible.search).toHaveBeenNthCalledWith(3, expect.objectContaining({ title: 'Confessor', author: undefined, isbn: undefined }));
});
it('keeps five ranked candidates per provider unless the search asks for more', async () => {
const books = Array.from({ length: 12 }, (_, index) => ({
...candidate(MetadataProviderKey.OPEN_LIBRARY, `ol${index}`, `Discworld ${index}`),
authors: ['Terry Pratchett'],
}));
const openLibrary: MetadataProvider = {
key: MetadataProviderKey.OPEN_LIBRARY,
label: 'OpenLibrary',
identifiable: false,
search: vi.fn().mockResolvedValue(books),
};
registry.select.mockReturnValue([openLibrary]);
const matching = await firstValueFrom(service.search({ author: 'Terry Pratchett' }).pipe(candidatesOnly(), toArray()));
const browsing = await firstValueFrom(service.search({ author: 'Terry Pratchett', resultsPerProvider: 20 }).pipe(candidatesOnly(), toArray()));
expect(matching).toHaveLength(5);
expect(browsing).toHaveLength(12);
expect(openLibrary.search).toHaveBeenLastCalledWith(expect.objectContaining({ resultsPerProvider: 20 }));
});
it('does not use title-only fallback for capped automated searches', async () => {
const audible: MetadataProvider = {
key: MetadataProviderKey.AUDIBLE,
@@ -275,8 +275,9 @@ export class MetadataFetchService {
private async searchAndRankProvider(run: ProviderRun): Promise<RankedCandidate[]> {
const { params } = run;
const limit = params.resultsPerProvider;
const primary = await run.search(params);
const primaryRanked = filterAndRank(primary.value ?? [], params);
const primaryRanked = filterAndRank(primary.value ?? [], params, limit);
if (primaryRanked.length > 0 || primary.stopped) return withCacheInfo(primaryRanked, primary);
const hasIsbn = hasText(params.isbn);
@@ -284,14 +285,14 @@ export class MetadataFetchService {
if (hasIsbn && hasFallbackTerms) {
const fallbackParams: MetadataSearchParams = { ...params, isbn: undefined };
const fallback = await run.search(fallbackParams);
const fallbackRanked = filterAndRank(fallback.value ?? [], fallbackParams);
const fallbackRanked = filterAndRank(fallback.value ?? [], fallbackParams, limit);
if (fallbackRanked.length > 0 || fallback.stopped) return withCacheInfo(fallbackRanked, fallback);
}
if (params.maxCandidatesPerProvider === undefined && hasText(params.title) && hasText(params.author)) {
const titleOnlyParams: MetadataSearchParams = { ...params, author: undefined, isbn: undefined };
const titleOnly = await run.search(titleOnlyParams);
return withCacheInfo(filterAndRank(titleOnly.value ?? [], titleOnlyParams), titleOnly);
return withCacheInfo(filterAndRank(titleOnly.value ?? [], titleOnlyParams, limit), titleOnly);
}
return [];
@@ -98,6 +98,16 @@ describe('GoogleProvider', () => {
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('q=isbn%3A1234567890'), expect.any(Object));
});
it('asks for a page of ten unless a browsing search wants more', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) });
await provider.search({ author: 'Terry Pratchett' });
await provider.search({ author: 'Terry Pratchett', resultsPerProvider: 20 });
const pageSizes = vi.mocked(global.fetch).mock.calls.map(([url]) => new URL(url as string).searchParams.get('maxResults'));
expect(pageSizes).toEqual(['10', '20']);
});
it('should return empty array on fetch error', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
@@ -9,14 +9,15 @@ import { fetchWithThrottle } from '../../fetch-with-throttle';
import { ProviderThrottleError } from '../../provider-throttle.error';
import { IdentifiableProvider } from '../metadata-provider';
import { MetadataSearchParams } from '../metadata-search-params';
import { pickSearchInputs, providerCacheContext } from '../provider-cache';
import { pickSearchInputs, providerCacheContext, widenedPageSize } from '../provider-cache';
import { PROVIDER_TIMEOUT_MS } from '../provider-constants';
import { buildRequestSignal } from '../provider-utils';
import { buildRequestSignal, searchPageSize } from '../provider-utils';
import { GoogleCoverValidator } from './google-cover-validator';
import { mapGoogleVolume } from './google.mapper';
import { GoogleBooksResponse, GoogleVolumeItem } from './google.types';
const BASE_URL = 'https://www.googleapis.com/books/v1';
const SEARCH_PAGE_SIZE = 10;
interface GoogleErrorBody {
error?: unknown;
@@ -43,7 +44,10 @@ export class GoogleProvider implements IdentifiableProvider {
},
// The query is the ISBN alone whenever one is given; title and author only matter without it.
describeSearch: (params) => ({
inputs: params.isbn ? { isbn: params.isbn } : pickSearchInputs(params, ['title', 'author']),
inputs: {
...(params.isbn ? { isbn: params.isbn } : pickSearchInputs(params, ['title', 'author'])),
pageSize: widenedPageSize(params, SEARCH_PAGE_SIZE),
},
coverValidation: params.validateCoverPlaceholders ?? false,
}),
describeLookup: (providerId) => ({ inputs: { id: providerId } }),
@@ -56,7 +60,7 @@ export class GoogleProvider implements IdentifiableProvider {
if (!enabled || !apiKey.trim()) return [];
const query = this.buildQuery(params);
if (!query) return [];
return this.fetchVolumes(query, apiKey, params.validateCoverPlaceholders ?? false, params.signal);
return this.fetchVolumes(query, apiKey, searchPageSize(params, SEARCH_PAGE_SIZE), params.validateCoverPlaceholders ?? false, params.signal);
}
async lookupById(providerId: string, signal?: AbortSignal): Promise<MetadataCandidate | null> {
@@ -102,8 +106,14 @@ export class GoogleProvider implements IdentifiableProvider {
return parts.length ? parts.join(' ') : null;
}
private async fetchVolumes(query: string, apiKey: string, validateCoverPlaceholders: boolean, signal?: AbortSignal): Promise<MetadataCandidate[]> {
const url = this.buildUrl('/volumes', { q: query, maxResults: '10', printType: 'books' }, apiKey);
private async fetchVolumes(
query: string,
apiKey: string,
pageSize: number,
validateCoverPlaceholders: boolean,
signal?: AbortSignal,
): Promise<MetadataCandidate[]> {
const url = this.buildUrl('/volumes', { q: query, maxResults: String(pageSize), printType: 'books' }, apiKey);
const startedAt = Date.now();
const safeQuery = sanitizeLogValue(query);
this.logger.log(`[google] [start] op=search query="${safeQuery}"`);
@@ -135,6 +135,26 @@ describe('HardcoverClient', () => {
expect(result).toEqual([]);
});
it('searches author names alone, each field with its own weight and typo allowance', async () => {
const mockFetch = vi.mocked(fetchWithThrottleModule.fetchWithThrottle);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: { search: { results: { hits: [{ document: { id: 1, title: 'Mort' } }] } } } }),
} as Response);
const documents = await client.searchBooksByAuthor('Terry Pratchett', apiKey, undefined, 20);
const requestBody = mockFetch.mock.calls[0][1]?.body;
expect(typeof requestBody).toBe('string');
if (typeof requestBody !== 'string') {
throw new TypeError('Expected the request body to be a string');
}
const body = JSON.parse(requestBody) as { query: string; variables: Record<string, unknown> };
expect(body.query).toContain('fields: "author_names", weights: "1", typos: "1"');
expect(body.variables).toEqual({ q: 'Terry Pratchett', limit: 20 });
expect(documents).toEqual([{ id: 1, title: 'Mort' }]);
});
it('returns empty array when fetch fails', async () => {
const mockFetch = vi.mocked(fetchWithThrottleModule.fetchWithThrottle);
mockFetch.mockRejectedValue(new Error('Network error'));
@@ -85,6 +85,17 @@ const SEARCH_BOOKS_QUERY = `
}
`;
// The default book search also matches titles, which ranks books about an author (quiz books,
// companions, diaries) above the ones they wrote. Each field searched needs its own weight and typo
// allowance, or the search answers with an error instead of hits.
const SEARCH_BOOKS_BY_AUTHOR_QUERY = `
query BookSearchByAuthor($q: String!, $limit: Int!) {
search(query: $q, query_type: "Book", per_page: $limit, page: 1, fields: "author_names", weights: "1", typos: "1") {
results
}
}
`;
const LOOKUP_BY_SLUG_QUERY = `
query BookBySlug($slug: String!, $releasedBy: date!) {
books(where: { slug: { _eq: $slug } }) {
@@ -135,19 +146,23 @@ export class HardcoverClient {
return body?.data?.books ?? [];
}
async searchBooks(query: string, apiKey: string, signal?: AbortSignal): Promise<HardcoverSearchDocument[]> {
const body = await this.post<HardcoverSearchResponse>(
'search',
SEARCH_BOOKS_QUERY,
{ q: query, limit: PROVIDER_LIMITS.DEFAULT_SEARCH_RESULTS },
apiKey,
signal,
);
const hits = body?.data?.search?.results?.hits;
if (body && !Array.isArray(hits)) markProviderFailed('hardcover.search: response without hits');
const documents = hits?.map((h) => h.document).filter((d): d is HardcoverSearchDocument => d != null) ?? [];
if (hits && documents.length < hits.length) markProviderDegraded('hardcover.search: hit without document');
return documents;
async searchBooks(
query: string,
apiKey: string,
signal?: AbortSignal,
limit: number = PROVIDER_LIMITS.DEFAULT_SEARCH_RESULTS,
): Promise<HardcoverSearchDocument[]> {
return this.search('search', SEARCH_BOOKS_QUERY, query, apiKey, signal, limit);
}
/** The books an author wrote, most read first. */
async searchBooksByAuthor(
author: string,
apiKey: string,
signal?: AbortSignal,
limit: number = PROVIDER_LIMITS.DEFAULT_SEARCH_RESULTS,
): Promise<HardcoverSearchDocument[]> {
return this.search('search-by-author', SEARCH_BOOKS_BY_AUTHOR_QUERY, author, apiKey, signal, limit);
}
async lookupBySlug(slug: string, apiKey: string, signal?: AbortSignal): Promise<HardcoverBookWithEditions | null> {
@@ -156,8 +171,24 @@ export class HardcoverClient {
return body?.data?.books?.[0] ?? null;
}
private async search(
op: 'search' | 'search-by-author',
graphql: string,
query: string,
apiKey: string,
signal: AbortSignal | undefined,
limit: number,
): Promise<HardcoverSearchDocument[]> {
const body = await this.post<HardcoverSearchResponse>(op, graphql, { q: query, limit }, apiKey, signal);
const hits = body?.data?.search?.results?.hits;
if (body && !Array.isArray(hits)) markProviderFailed(`hardcover.${op}: response without hits`);
const documents = hits?.map((h) => h.document).filter((d): d is HardcoverSearchDocument => d != null) ?? [];
if (hits && documents.length < hits.length) markProviderDegraded(`hardcover.${op}: hit without document`);
return documents;
}
private async post<T>(
op: 'search-by-isbn' | 'search' | 'lookup',
op: 'search-by-isbn' | 'search' | 'search-by-author' | 'lookup',
query: string,
variables: Record<string, unknown>,
apiKey: string,
@@ -59,6 +59,7 @@ describe('HardcoverProvider', () => {
useValue: {
searchByIsbn: vi.fn().mockResolvedValue([]),
searchBooks: vi.fn().mockResolvedValue([]),
searchBooksByAuthor: vi.fn().mockResolvedValue([]),
lookupBySlug: vi.fn().mockResolvedValue(null),
},
},
@@ -189,7 +190,7 @@ describe('HardcoverProvider', () => {
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');
expect(client.searchBooks).toHaveBeenCalledWith('The Name of the Wind Patrick Rothfuss', 'test-key', undefined, 10);
});
it('retries with title only when title+author returns no results', async () => {
@@ -198,8 +199,8 @@ describe('HardcoverProvider', () => {
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(client.searchBooks).toHaveBeenNthCalledWith(1, 'The Name of the Wind Patrick Rothfuss', 'test-key', undefined, 10);
expect(client.searchBooks).toHaveBeenNthCalledWith(2, 'The Name of the Wind', 'test-key', undefined, 10);
expect(results).toHaveLength(1);
});
@@ -209,7 +210,7 @@ describe('HardcoverProvider', () => {
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');
expect(client.searchBooks).toHaveBeenCalledWith('The Name of the Wind', 'test-key', undefined, 10);
});
it('returns empty array when title search returns nothing', async () => {
@@ -224,7 +225,37 @@ describe('HardcoverProvider', () => {
await provider.search({ title: 'Test' });
expect(client.searchBooks).toHaveBeenCalledWith('Test', 'my-secret-key');
expect(client.searchBooks).toHaveBeenCalledWith('Test', 'my-secret-key', undefined, 10);
});
it('browses by author name when no title is given', async () => {
vi.spyOn(client, 'searchBooksByAuthor').mockResolvedValue([mockDocument]);
const results = await provider.search({ author: 'Terry Pratchett' });
expect(client.searchBooksByAuthor).toHaveBeenCalledWith('Terry Pratchett', 'test-key', undefined, 10);
expect(client.searchBooks).not.toHaveBeenCalled();
expect(results).toHaveLength(1);
});
it('does not browse by author after an ISBN search finds nothing', async () => {
vi.spyOn(client, 'searchByIsbn').mockResolvedValue([]);
vi.spyOn(client, 'searchBooksByAuthor');
expect(await provider.search({ isbn: '9780756404079', author: 'Patrick Rothfuss' })).toEqual([]);
expect(client.searchBooksByAuthor).not.toHaveBeenCalled();
expect(client.searchBooks).not.toHaveBeenCalled();
});
it('widens the page when a browsing search asks for more results', async () => {
vi.spyOn(client, 'searchBooks').mockResolvedValue([mockDocument]);
vi.spyOn(client, 'searchBooksByAuthor').mockResolvedValue([mockDocument]);
await provider.search({ author: 'Terry Pratchett', resultsPerProvider: 20 });
await provider.search({ title: 'Mort', resultsPerProvider: 20 });
expect(client.searchBooksByAuthor).toHaveBeenCalledWith('Terry Pratchett', 'test-key', undefined, 20);
expect(client.searchBooks).toHaveBeenCalledWith('Mort', 'test-key', undefined, 20);
});
});
@@ -7,7 +7,9 @@ import type { ProviderCacheSpec } from '../../../../common/provider-cache/provid
import { candidateHasNormalizedIsbn, normalizeMetadataIsbn } from '../../isbn-match';
import { IdentifiableProvider } from '../metadata-provider';
import { MetadataSearchParams } from '../metadata-search-params';
import { pickSearchInputs, providerCacheContext } from '../provider-cache';
import { pickSearchInputs, providerCacheContext, widenedPageSize } from '../provider-cache';
import { PROVIDER_LIMITS } from '../provider-constants';
import { searchPageSize } from '../provider-utils';
import { HardcoverClient } from './hardcover.client';
import { mapBookWithEditions, mapSearchDocument } from './hardcover.mapper';
import { HardcoverBookWithEditions, HardcoverSearchDocument } from './hardcover.types';
@@ -55,6 +57,7 @@ export class HardcoverProvider implements IdentifiableProvider {
inputs: {
...pickSearchInputs(params, ['title', 'author', 'isbn', 'hardcoverEditionId']),
isAudiobook: editionMedium(params) === 'audio',
pageSize: widenedPageSize(params, PROVIDER_LIMITS.DEFAULT_SEARCH_RESULTS),
},
}),
describeLookup: (providerId, params) => ({
@@ -88,20 +91,24 @@ export class HardcoverProvider implements IdentifiableProvider {
}
}
if (!params.title) return [];
const pageSize = searchPageSize(params, PROVIDER_LIMITS.DEFAULT_SEARCH_RESULTS);
// An author alone is a browsing search for what they wrote. After a missed ISBN it is not: the
// caller retries without the ISBN when it wants that.
if (!params.title) {
if (!params.author || params.isbn) return [];
return (await this.client.searchBooksByAuthor(params.author, apiKey, signal, pageSize)).map(mapDocument);
}
const searchDocuments = async (query: string) => (await this.client.searchBooks(query, apiKey, signal, pageSize)).map(mapDocument);
if (params.author) {
const docs = signal
? await this.client.searchBooks(`${params.title} ${params.author}`, apiKey, signal)
: await this.client.searchBooks(`${params.title} ${params.author}`, apiKey);
if (docs.length > 0) {
return docs.map(mapDocument);
}
const candidates = await searchDocuments(`${params.title} ${params.author}`);
if (candidates.length > 0) return candidates;
this.logger.debug(`Hardcover: no results for title+author, retrying with title only`);
}
const docs = signal ? await this.client.searchBooks(params.title, apiKey, signal) : await this.client.searchBooks(params.title, apiKey);
return docs.map(mapDocument);
return searchDocuments(params.title);
}
async lookupById(providerId: string, signal?: AbortSignal, params?: MetadataSearchParams): Promise<MetadataCandidate | null> {
@@ -29,6 +29,9 @@ export interface MetadataSearchParams {
// Hint for providers to cap deep candidate exploration in non-interactive flows
// (e.g. auto-fill/background refresh where there is no manual candidate picking).
maxCandidatesPerProvider?: number;
// How many ranked candidates each provider contributes. Browsing searches ask for more than matching
// one book needs; providers that can return that many from one request widen their page to match.
resultsPerProvider?: number;
// Automatic pipelines must not select a ComicVine run from equally plausible volume matches.
requireUnambiguousMatch?: boolean;
// Interactive result lists can afford bounded cover checks. Bulk metadata pipelines must not
@@ -44,9 +44,35 @@ describe('OpenLibraryProvider', () => {
expect(result).toEqual([]);
});
it('should return empty array if no isbn or title provided', async () => {
const result = await provider.search({ author: 'Test' });
it('should return empty array if no isbn, title or author provided', async () => {
global.fetch = vi.fn();
const result = await provider.search({});
expect(result).toEqual([]);
expect(global.fetch).not.toHaveBeenCalled();
});
it('should browse by author when no title is given', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ docs: [{ key: '/works/OL2W', title: 'Mort', author_name: ['Terry Pratchett'] }] }),
});
const result = await provider.search({ author: 'Terry Pratchett' });
const url = new URL(vi.mocked(global.fetch).mock.calls[0][0] as string);
expect(url.searchParams.get('author')).toBe('Terry Pratchett');
expect(url.searchParams.has('title')).toBe(false);
expect(url.searchParams.get('limit')).toBe('10');
expect(result.map((candidate) => candidate.title)).toEqual(['Mort']);
});
it('should widen the page when a browsing search asks for more results', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ docs: [] }) });
await provider.search({ author: 'Terry Pratchett', resultsPerProvider: 20 });
const url = new URL(vi.mocked(global.fetch).mock.calls[0][0] as string);
expect(url.searchParams.get('limit')).toBe('20');
});
it('should fetch from openlibrary and return mapped docs', async () => {
@@ -8,13 +8,14 @@ import { fetchWithThrottle } from '../../fetch-with-throttle';
import { ProviderThrottleError } from '../../provider-throttle.error';
import { IdentifiableProvider } from '../metadata-provider';
import { MetadataSearchParams } from '../metadata-search-params';
import { pickSearchInputs, providerCacheContext } from '../provider-cache';
import { pickSearchInputs, providerCacheContext, widenedPageSize } from '../provider-cache';
import { PROVIDER_TIMEOUT_MS } from '../provider-constants';
import { buildRequestSignal } from '../provider-utils';
import { buildRequestSignal, searchPageSize } from '../provider-utils';
import { mapOpenLibraryDoc, mapOpenLibraryWork } from './open-library.mapper';
import { OpenLibraryDoc, OpenLibrarySearchResponse, OpenLibraryWork } from './open-library.types';
const BASE_URL = 'https://openlibrary.org';
const SEARCH_PAGE_SIZE = 10;
const SEARCH_FIELDS =
'key,title,author_name,first_publish_year,isbn,cover_i,publisher,language,number_of_pages_median,subject,ratings_average,ratings_count';
@@ -34,7 +35,12 @@ export class OpenLibraryProvider implements IdentifiableProvider {
authoritativeLookupStatuses: [404],
context: async () => providerCacheContext((await this.providerConfig.getConfig()).openLibrary.enabled),
// The ISBN alone forms the query whenever one is given; title and author only matter without it.
describeSearch: (params) => ({ inputs: params.isbn ? { isbn: params.isbn } : pickSearchInputs(params, ['title', 'author']) }),
describeSearch: (params) => ({
inputs: {
...(params.isbn ? { isbn: params.isbn } : pickSearchInputs(params, ['title', 'author'])),
pageSize: widenedPageSize(params, SEARCH_PAGE_SIZE),
},
}),
describeLookup: (providerId) => ({ inputs: { id: providerId } }),
};
@@ -46,7 +52,7 @@ export class OpenLibraryProvider implements IdentifiableProvider {
const query = this.buildSearchParams(params);
if (!query) return [];
query.set('limit', '10');
query.set('limit', String(searchPageSize(params, SEARCH_PAGE_SIZE)));
query.set('fields', SEARCH_FIELDS);
const url = `${BASE_URL}/search.json?${query}`;
@@ -120,15 +126,13 @@ export class OpenLibraryProvider implements IdentifiableProvider {
}
private buildSearchParams(params: MetadataSearchParams): URLSearchParams | null {
if (!params.isbn && !params.title) return null;
if (params.isbn) return new URLSearchParams({ isbn: params.isbn });
if (!params.title && !params.author) return null;
// An author alone is a browsing search: every work Open Library files under that name.
const query = new URLSearchParams();
if (params.isbn) {
query.set('isbn', params.isbn);
} else {
query.set('title', params.title!);
if (params.author) query.set('author', params.author);
}
if (params.title) query.set('title', params.title);
if (params.author) query.set('author', params.author);
return query;
}
@@ -118,6 +118,7 @@ type Mutation =
| 'titleIsExplicitQuery'
| 'validateCoverPlaceholders'
| 'maxCandidatesPerProvider'
| 'resultsPerProvider'
| 'requireUnambiguousMatch'
| 'hardcoverEditionId'
| 'isAudiobook'
@@ -133,6 +134,7 @@ const MUTATIONS: Record<Mutation, (params: MetadataSearchParams) => MetadataSear
titleIsExplicitQuery: (params) => ({ ...params, titleIsExplicitQuery: true }),
validateCoverPlaceholders: (params) => ({ ...params, validateCoverPlaceholders: false }),
maxCandidatesPerProvider: (params) => ({ ...params, maxCandidatesPerProvider: 1 }),
resultsPerProvider: (params) => ({ ...params, resultsPerProvider: 20 }),
requireUnambiguousMatch: (params) => ({ ...params, requireUnambiguousMatch: true }),
hardcoverEditionId: (params) => ({ ...params, hardcoverEditionId: '99' }),
// Leaves the audiobook gate on, since includeAudiobookProviders is set.
@@ -166,7 +168,7 @@ const CASES: DeclarationCase[] = [
key: MetadataProviderKey.GOOGLE,
withIsbn: ISBN_ONLY,
withoutIsbn: TITLE_AUTHOR,
searchReads: ['validateCoverPlaceholders'],
searchReads: ['validateCoverPlaceholders', 'resultsPerProvider'],
honoursMax: false,
lookup: LOOKUP_ID,
fingerprint: {},
@@ -246,7 +248,7 @@ const CASES: DeclarationCase[] = [
key: MetadataProviderKey.HARDCOVER,
withIsbn: { ...TITLE_AUTHOR, ...ISBN_ONLY, isAudiobook: true, hardcoverEditionId: '42' },
withoutIsbn: { ...TITLE_AUTHOR, isAudiobook: true, hardcoverEditionId: '42' },
searchReads: ['isAudiobook', 'hardcoverEditionId'],
searchReads: ['isAudiobook', 'hardcoverEditionId', 'resultsPerProvider'],
honoursMax: false,
lookup: {
inputs: { id: 'provider-id', isAudiobook: true, hardcoverEditionId: '42', isbn: '9780441172719' },
@@ -267,7 +269,7 @@ const CASES: DeclarationCase[] = [
key: MetadataProviderKey.OPEN_LIBRARY,
withIsbn: ISBN_ONLY,
withoutIsbn: TITLE_AUTHOR,
searchReads: [],
searchReads: ['resultsPerProvider'],
honoursMax: false,
lookup: LOOKUP_ID,
fingerprint: {},
@@ -409,6 +411,18 @@ describe('provider cache declarations', () => {
});
describe('special cases', () => {
it.each([MetadataProviderKey.GOOGLE, MetadataProviderKey.HARDCOVER, MetadataProviderKey.OPEN_LIBRARY])(
'keys %s on a widened page only, so a default-sized answer keeps its key',
(key) => {
const spec = specOf(key);
const pageSize = (resultsPerProvider: number | undefined) => spec.describeSearch({ ...WITHOUT_ISBN, resultsPerProvider })?.inputs.pageSize;
expect(pageSize(undefined)).toBeUndefined();
expect(pageSize(5)).toBeUndefined();
expect(pageSize(10)).toBeUndefined();
expect(pageSize(20)).toBe(20);
},
);
it('keys iTunes searches on an absent medium as an ebook, but keeps lookups tri-state', () => {
const spec = specOf(MetadataProviderKey.ITUNES);
const absent = { ...WITHOUT_ISBN, isAudiobook: undefined };
@@ -1,7 +1,7 @@
import { canonicalJson } from '../../../common/provider-cache/canonicalize';
import type { ProviderCacheContext } from '../../../common/provider-cache/provider-cache-spec';
import { MetadataSearchParams } from './metadata-search-params';
import { allowsAudiobookProviders } from './provider-utils';
import { allowsAudiobookProviders, searchPageSize } from './provider-utils';
/**
* Shared pieces of each provider's cache declaration. Keep key inputs conservative: every value the
@@ -26,6 +26,15 @@ export function audiobookGate(params: MetadataSearchParams): boolean {
return allowsAudiobookProviders(params);
}
/**
* A widened page holds records the default one does not, so its size is part of the key. The default
* size stays out of it, which keeps every answer stored before pages could widen addressable.
*/
export function widenedPageSize(params: MetadataSearchParams, defaultSize: number): number | undefined {
const size = searchPageSize(params, defaultSize);
return size === defaultSize ? undefined : size;
}
/** Absent means the provider's own default (its largest), matching how providers read the cap. */
export function requestedMax(params: MetadataSearchParams): number | null {
const value = params.maxCandidatesPerProvider;
@@ -16,6 +16,13 @@ export function normalizeMaxCandidates(value: number | undefined, maxResults: nu
return Math.min(rounded, maxResults);
}
/** How many records one search request asks for: the provider's own page, widened when a browsing search wants more. */
export function searchPageSize(params: MetadataSearchParams, defaultSize: number): number {
const wanted = params.resultsPerProvider;
if (wanted === undefined || !Number.isFinite(wanted)) return defaultSize;
return Math.max(defaultSize, Math.floor(wanted));
}
export function buildRequestSignal(timeoutMs: number, parentSignal?: AbortSignal): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
if (!parentSignal) return timeoutSignal;