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
@@ -266,6 +266,28 @@ describe('RequestSearchPanel provider sources', () => {
]
})
it('asks every provider for a browsing page of results', async () => {
const wrapper = await render()
await wrapper.find('#request-author').setValue('Terry Pratchett')
await wrapper.find('form').trigger('submit')
expect(state.search).toHaveBeenLastCalledWith({ title: undefined, author: 'Terry Pratchett', mediaKind: 'ebook', resultsPerProvider: 20 })
})
it('searches for an author alone when their name on a result is pressed', async () => {
const scrollIntoView = vi.fn<() => void>()
Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: scrollIntoView })
const wrapper = await render()
await wrapper.find('#request-title').setValue('One Dark Window')
await wrapper.find('button[aria-label="Show books by Rachel Gillig"]').trigger('click')
expect((wrapper.find('#request-title').element as HTMLInputElement).value).toBe('')
expect((wrapper.find('#request-author').element as HTMLInputElement).value).toBe('Rachel Gillig')
expect(state.search).toHaveBeenLastCalledWith({ title: undefined, author: 'Rachel Gillig', mediaKind: 'ebook', resultsPerProvider: 20 })
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
})
it('shows every contributing provider as an icon or initials badge', async () => {
const wrapper = await render()
const sources = wrapper.get('[role="img"]')
@@ -685,6 +707,92 @@ describe('RequestSearchPanel free-text fallback', () => {
expect(wrapper.text()).not.toContain(ACTION)
})
describe('for a requester whose requests go to an approver', () => {
const REQUEST_ANYWAY = 'Request anyway'
beforeEach(() => {
state.hasPermission.mockImplementation((permission: string) => permission === Permission.BookRequestAccess)
})
async function requestAnyway(wrapper: Awaited<ReturnType<typeof render>>) {
await wrapper
.findAll('button')
.find((button) => button.text() === REQUEST_ANYWAY)!
.trigger('click')
await new Promise((resolve) => setTimeout(resolve, 0))
}
it('offers the typed title as a request once a provider search comes back empty', async () => {
const wrapper = await typeTitle(await render())
expect(wrapper.text()).toContain(REQUEST_ANYWAY)
expect(wrapper.text()).toContain('A note for the approver under More options helps them find the right book.')
})
it('does not offer it before a search has been run', async () => {
state.hasSearched.value = false
const wrapper = await typeTitle(await render())
expect(wrapper.text()).not.toContain(REQUEST_ANYWAY)
})
it('leaves out the approver for a requester whose requests approve themselves', async () => {
state.hasPermission.mockImplementation(
(permission: string) => permission === Permission.BookRequestAccess || permission === Permission.BookRequestAutoApprove,
)
const wrapper = await typeTitle(await render())
expect(wrapper.text()).toContain(REQUEST_ANYWAY)
expect(wrapper.text()).not.toContain('approver under More options')
})
it('says what the queue already has, before anything is created', async () => {
state.checkFreeText.mockResolvedValue({ ownedBookId: null, existingRequestId: 5, alreadySubscribed: false })
const wrapper = await typeTitle(await render())
await settleHint(wrapper)
expect(wrapper.text()).toContain('Already requested by someone else')
expect(state.submitFreeText).not.toHaveBeenCalled()
})
it('sends it with the note to the approver and stays on the search', async () => {
state.submitFreeText.mockResolvedValue({ request: { id: 32, userId: 7, status: 'pending' }, subscribed: false })
const wrapper = await typeTitle(await render())
await wrapper.find('#request-note').setValue('The 1998 paperback')
await requestAnyway(wrapper)
expect(state.submitFreeText).toHaveBeenCalledWith(
expect.objectContaining({ title: 'An Untracked Novel' }),
expect.objectContaining({ selfServe: false, note: 'The 1998 paperback' }),
)
expect(state.push).not.toHaveBeenCalled()
expect(wrapper.emitted('submitted')).toHaveLength(1)
})
it('requests both media as typed in pair mode', async () => {
state.pairMode.value = true
state.submitPairFreeText.mockResolvedValue({
ebook: { request: { id: 40, userId: 7, status: 'pending' }, subscribed: false },
audiobook: { request: { id: 41, userId: 7, status: 'pending' }, subscribed: false },
})
try {
const wrapper = await typeTitle(await render())
await requestAnyway(wrapper)
expect(state.submitPairFreeText).toHaveBeenCalledWith(
expect.objectContaining({ title: 'An Untracked Novel' }),
expect.objectContaining({ selfServe: false }),
)
expect(state.push).not.toHaveBeenCalled()
} finally {
state.pairMode.value = false
}
})
})
it('does not offer it before a search has been run', async () => {
state.hasSearched.value = false
@@ -24,6 +24,7 @@ import {
isBookRequestFulfiller,
isGrabbableBookRequestStatus,
MAX_BOOK_REQUEST_SEARCH_ISBNS,
METADATA_SEARCH_MAX_RESULTS_PER_PROVIDER,
Permission,
} from '@bookorbit/types'
import type {
@@ -120,6 +121,8 @@ const FORMATS_BY_MEDIA = BOOK_REQUEST_FORMATS_BY_MEDIA_KIND
const title = ref('')
const author = ref('')
const searchCard = ref<HTMLElement | null>(null)
const authorInput = ref<HTMLInputElement | null>(null)
const language = ref<string | null>(null)
/**
* A preference, not a filter: a release in another format still appears, it simply scores lower.
@@ -259,9 +262,24 @@ async function runSearch() {
title: title.value.trim() || undefined,
author: author.value.trim() || undefined,
...(pairMode.value ? { mediaKinds: [...BOOK_REQUEST_MEDIA_KINDS] } : { mediaKind: mediaKind.value }),
// Looking for something to request is browsing, so an author's search should show their books, not a best match.
resultsPerProvider: METADATA_SEARCH_MAX_RESULTS_PER_PROVIDER,
})
}
/**
* An author on a result is a way to see more of their books: the search becomes that author alone.
* The list the name sat in is replaced, so the query comes into view, and keyboard focus moves to it.
* A tap leaves focus alone, since focusing a field would open the on-screen keyboard over the results.
*/
async function searchAuthor(name: string, event: MouseEvent) {
title.value = ''
author.value = name
searchCard.value?.scrollIntoView({ block: 'nearest' })
if (event.detail === 0) authorInput.value?.focus({ preventScroll: true })
await runSearch()
}
const showCacheNotice = computed(() => hasSearched.value && cacheSummary.value.cachedCount > 0)
// Offered after every finished search: a provider whose saved answer was "nothing" leaves no result to mark.
const showSearchFresh = computed(() => hasSearched.value && !isStreaming.value && !showCacheNotice.value)
@@ -568,21 +586,29 @@ function pairMediumLabel(kind: BookRequestMediaKind): string {
}
/**
* Only once a provider search has come back empty, and only for somebody who will pick the release
* themselves. An approver handed a row that says nothing but a typed string has no way to tell
* whether it is the book that was meant.
* Only once a provider search has come back empty. Somebody who picks the release themselves goes
* straight to the indexers with what they typed. Anyone else may still request it as typed: the
* provider catalogues are not the limit of what an indexer has, and the note for the approver is
* where they say which book they mean, since the row carries no cover or ISBN to tell by.
*/
const canSearchIndexersManually = computed(() => selfFulfils.value && hasSearched.value && title.value.trim() !== '')
const showFreeTextOption = computed(() => canSearchIndexersManually.value && groups.value.length === 0)
const showManualSearchOverride = computed(() => canSearchIndexersManually.value && groups.value.length > 0)
const showRequestAnyway = computed(() => !selfFulfils.value && hasSearched.value && title.value.trim() !== '' && groups.value.length === 0)
const offersTypedRequest = computed(() => showFreeTextOption.value || showRequestAnyway.value)
const freeTextBusy = ref(false)
const freeTextNotice = ref<string | null>(null)
/** One sentence either way: the indexer fallback is a longer answer to the same question. */
const noResultsHint = computed(() =>
showFreeTextOption.value ? t('bookRequests.search.noResultsIndexerHint', { title: title.value.trim() }) : t('bookRequests.search.noResultsHint'),
)
/** One sentence either way: the typed-request fallback is a longer answer to the same question. */
const noResultsHint = computed(() => {
const typed = { title: title.value.trim() }
if (showFreeTextOption.value) return t('bookRequests.search.noResultsIndexerHint', typed)
if (showRequestAnyway.value) {
return autoApproves.value ? t('bookRequests.search.noResultsRequestAutoHint', typed) : t('bookRequests.search.noResultsRequestHint', typed)
}
return t('bookRequests.search.noResultsHint')
})
/**
* What the library and the queue already know about the typed text, shown before anything is
@@ -598,11 +624,11 @@ const FREE_TEXT_HINT_DEBOUNCE_MS = 350
let hintHandle: ReturnType<typeof setTimeout> | null = null
let hintGeneration = 0
watch([showFreeTextOption, title, author, mediaKind], () => {
watch([offersTypedRequest, title, author, mediaKind], () => {
freeTextNotice.value = null
hintGeneration++
if (hintHandle !== null) clearTimeout(hintHandle)
if (!showFreeTextOption.value) return
if (!offersTypedRequest.value) return
const generation = hintGeneration
hintHandle = setTimeout(async () => {
@@ -650,19 +676,24 @@ function releasesQuery(searchIsbn?: string | null): LocationQuery {
return searchIsbn ? { ...carried, isbn: searchIsbn } : { ...carried, search: 'title-author' }
}
async function startFreeTextSearch() {
/**
* Requests what was typed, with no provider record behind it. A self-fulfiller goes on to pick the
* release; anyone else leaves it to the approver, or to automation when their requests auto-approve,
* both of which search the indexers by the same title and author.
*/
async function submitTypedRequest(selfServe: boolean) {
freeTextBusy.value = true
try {
if (pairMode.value) {
const pair = await submitPairFreeText(
{ title: title.value, author: author.value },
{ language: language.value, selfServe: true, note: note.value.trim() || null, ...pairHalves() },
{ language: language.value, selfServe, note: note.value.trim() || null, ...pairHalves() },
)
if (!pair) {
toast.error(submitFailureText(lastFailure.value, t) ?? t('bookRequests.search.submitFailed'))
return
}
await afterPairSubmit(pair, true)
await afterPairSubmit(pair, selfServe)
return
}
@@ -672,7 +703,7 @@ async function startFreeTextSearch() {
targetLibraryId: targetLibraryId.value,
targetFolderId: targetFolderId.value,
language: language.value,
selfServe: true,
selfServe,
...requestExtras(),
},
)
@@ -680,12 +711,20 @@ async function startFreeTextSearch() {
toast.error(submitFailureText(lastFailure.value, t) ?? t('bookRequests.search.submitFailed'))
return
}
await afterSubmit(result, true)
await afterSubmit(result, selfServe)
} finally {
freeTextBusy.value = false
}
}
async function startFreeTextSearch() {
await submitTypedRequest(true)
}
async function requestAnyway() {
await submitTypedRequest(false)
}
async function requestGroup(group: CandidateGroup) {
const choice = recommendedSearchChoice(group)
await requestGroupChoice(group, choice?.candidate ?? group.candidate, choice?.isbn ?? null, choosesRelease.value)
@@ -842,7 +881,7 @@ function mediaIconFor(kind: BookRequestMediaKind) {
because a transparent input reads as a caption, and the secondary controls sit in a strip
attached to the same card rather than floating on their own line below it.
-->
<div class="overflow-hidden rounded-xl border border-border bg-card">
<div ref="searchCard" class="overflow-hidden rounded-xl border border-border bg-card">
<div class="flex flex-col gap-2.5 p-3 sm:flex-row sm:flex-wrap sm:items-center">
<div
class="flex h-9 w-full items-center gap-2 rounded-md border border-input bg-background px-3 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/40 sm:w-auto sm:min-w-[13rem] sm:flex-[2]"
@@ -865,6 +904,7 @@ function mediaIconFor(kind: BookRequestMediaKind) {
<label for="request-author" class="sr-only">{{ t('bookRequests.search.authorLabel') }}</label>
<input
id="request-author"
ref="authorInput"
v-model="author"
type="text"
class="h-full w-full min-w-0 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none"
@@ -1098,9 +1138,7 @@ function mediaIconFor(kind: BookRequestMediaKind) {
<!--
One card, on the same edges as the search card above it. The escape hatch is the answer to an
empty search rather than a second notice about it, so it shares this block instead of adding a
second one indented to its own left edge. It is offered only to somebody who can fulfil the
request themselves: without the picker on the other side it would create a request an approver
could not identify.
second one indented to its own left edge.
-->
<div
v-else-if="hasSearched && groups.length === 0"
@@ -1123,6 +1161,16 @@ function mediaIconFor(kind: BookRequestMediaKind) {
>
{{ t('bookRequests.search.freeTextAction') }}
</Button>
<Button
v-else-if="showRequestAnyway"
variant="outline"
size="sm"
class="w-full shrink-0 sm:w-auto"
:disabled="freeTextBusy || freeTextDestinationMissing"
@click="requestAnyway"
>
{{ t('bookRequests.search.requestAnywayAction') }}
</Button>
</div>
<p v-if="interruptedMessage" role="status" class="text-sm text-muted-foreground">{{ interruptedMessage }}</p>
@@ -1162,7 +1210,20 @@ function mediaIconFor(kind: BookRequestMediaKind) {
<div class="min-w-0 flex-1 space-y-1">
<p class="truncate text-[15px] font-semibold text-foreground">{{ group.title }}</p>
<p v-if="group.authors.length" class="truncate text-sm text-muted-foreground">{{ group.authors.join(', ') }}</p>
<p v-if="group.authors.length" class="truncate text-sm text-muted-foreground">
<template v-for="(name, index) in group.authors" :key="name">
<span v-if="index > 0">, </span>
<button
type="button"
class="inline rounded-sm underline decoration-border underline-offset-2 transition-colors hover:text-foreground hover:decoration-current focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
:title="t('bookRequests.search.searchAuthor', { author: name })"
:aria-label="t('bookRequests.search.searchAuthor', { author: name })"
@click="searchAuthor(name, $event)"
>
{{ name }}
</button>
</template>
</p>
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span class="tabular-nums">{{ group.publishedYear ?? t('bookRequests.search.yearUnknown') }}</span>
@@ -152,6 +152,23 @@ describe('useMetadataSearch', () => {
expect(state.isStreaming.value).toBe(false)
})
it('asks for more results per provider only when the search says so, and keeps asking on a fresh search', async () => {
apiMock.mockResolvedValue({ ok: false })
const scope = effectScope()
const state = scope.run(() => useMetadataSearch())!
await state.search({ author: 'Terry Pratchett', resultsPerProvider: 20 })
await state.searchFresh()
await state.search({ title: 'Dune' })
scope.stop()
expect(apiMock.mock.calls.map(([input]) => String(input))).toEqual([
'/api/v1/metadata-fetch/stream?author=Terry+Pratchett&resultsPerProvider=20',
'/api/v1/metadata-fetch/stream?author=Terry+Pratchett&cache=refresh&resultsPerProvider=20',
'/api/v1/metadata-fetch/stream?title=Dune',
])
})
it('requests the sole available provider when no provider filter is selected', async () => {
apiMock.mockResolvedValue({ ok: false })
@@ -29,6 +29,8 @@ export interface SearchParams {
providers?: MetadataProviderKey[]
/** `refresh` skips saved answers and asks every provider live. */
cache?: MetadataCacheRequestPolicy
/** Ranked results each provider may return, for a search that browses rather than matches one book. */
resultsPerProvider?: number
}
export function useMetadataSearch() {
@@ -83,6 +85,7 @@ export function useMetadataSearch() {
const requestedProviders = params.providers ?? (selectedProviders.value.length ? selectedProviders.value : onlyProvider ? [onlyProvider.key] : [])
if (requestedProviders.length) query.set('providers', requestedProviders.join(','))
if (params.cache === METADATA_CACHE_REFRESH) query.set('cache', METADATA_CACHE_REFRESH)
if (params.resultsPerProvider != null) query.set('resultsPerProvider', String(params.resultsPerProvider))
return query
}
+4
View File
@@ -8124,6 +8124,10 @@
"noResultsHint": "Try a different title or author.",
"noResultsIndexerHint": "Try a different title or author, or search the indexers for \"{title}\" and pick a release yourself.",
"freeTextAction": "Search indexers",
"noResultsRequestHint": "Try a different title or author, or request \"{title}\" as typed. A note for the approver under More options helps them find the right book.",
"noResultsRequestAutoHint": "Try a different title or author, or request \"{title}\" as typed.",
"requestAnywayAction": "Request anyway",
"searchAuthor": "Show books by {author}",
"manualSearchAction": "Search indexers manually",
"yearUnknown": "Year unknown",
"sourceCount": "{count, plural, one {# source} other {# sources}}",
+6
View File
@@ -25,6 +25,12 @@ export const SERVER_FEATURES = [
* review reports `heldOnRequest`, `releaseInfoUrl`, `edition` and `hasCover` with a cover route.
*/
"book-request-review-hold",
/**
* `GET /metadata-fetch/stream` accepts `resultsPerProvider` (up to
* `METADATA_SEARCH_MAX_RESULTS_PER_PROVIDER`), and Open Library and Hardcover answer a search that
* names an author but no title.
*/
"metadata-search-results-per-provider",
] as const;
export type ServerFeature = (typeof SERVER_FEATURES)[number];
+7
View File
@@ -150,6 +150,13 @@ export interface MetadataProviderInfo {
*/
export const METADATA_PROVIDER_STATUS_EVENT = "provider-status";
/**
* The most ranked candidates one provider may contribute to a metadata search stream. A search for
* one book's match keeps the server's small default; a search that browses, such as finding a book
* to request, asks for up to this many with `resultsPerProvider`.
*/
export const METADATA_SEARCH_MAX_RESULTS_PER_PROVIDER = 20;
/** Why a provider stopped before finishing, so an interrupted search is not read as an empty one. */
export type MetadataProviderSearchOutcome = "timeout" | "throttled" | "failed";
@@ -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;