mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): restrict autofill to stored provider ids
Prevent automatic metadata refreshes from searching alternate identities when a book already has trusted provider IDs. Preserve discovery and manual search behavior, and avoid dirtying descriptions on empty refreshes. Closes #1222
This commit is contained in:
@@ -1889,7 +1889,7 @@ watch(
|
||||
</h3>
|
||||
|
||||
<dl class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[6px] last:border-b-0">
|
||||
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.publisher') }}</dt>
|
||||
<template v-if="book.publisher">
|
||||
<Tooltip>
|
||||
@@ -1901,37 +1901,37 @@ watch(
|
||||
</template>
|
||||
<dd v-else class="truncate text-[13px] font-medium">-</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[6px] last:border-b-0">
|
||||
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.published') }}</dt>
|
||||
<dd class="truncate text-[13px] font-medium">
|
||||
{{ book.publishedDate ? formatDisplayDate(book.publishedDate) : book.publishedYear || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[6px] last:border-b-0">
|
||||
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.language') }}</dt>
|
||||
<dd class="truncate text-[13px] font-medium capitalize">{{ book.language || '-' }}</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[6px] last:border-b-0">
|
||||
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.pages') }}</dt>
|
||||
<dd class="truncate text-[13px] font-medium">{{ book.pageCount || '-' }}</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="book.audioMetadata?.durationSeconds != null"
|
||||
class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0"
|
||||
class="flex items-baseline justify-between gap-3 border-b border-border py-[6px] last:border-b-0"
|
||||
>
|
||||
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.duration') }}</dt>
|
||||
<dd class="truncate text-[13px] font-medium">{{ formatDuration(book.audioMetadata.durationSeconds) }}</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="book.audioMetadata?.durationSeconds != null"
|
||||
class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0"
|
||||
class="flex items-baseline justify-between gap-3 border-b border-border py-[6px] last:border-b-0"
|
||||
>
|
||||
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.edition') }}</dt>
|
||||
<dd class="truncate text-[13px] font-medium">
|
||||
{{ book.audioMetadata.abridged ? t('book.detail.details.abridged') : t('book.detail.details.unabridged') }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[6px] last:border-b-0">
|
||||
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.isbn') }}</dt>
|
||||
<dd v-if="book.isbn13 || book.isbn10" class="truncate text-right text-[13px] font-medium font-mono">
|
||||
<div v-if="book.isbn13">{{ book.isbn13 }}</div>
|
||||
@@ -1939,15 +1939,15 @@ watch(
|
||||
</dd>
|
||||
<dd v-else class="truncate text-[13px] font-medium">-</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[6px] last:border-b-0">
|
||||
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.fileSize') }}</dt>
|
||||
<dd class="truncate text-[13px] font-medium">{{ formatFileSize(primaryFile?.sizeBytes) }}</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[6px] last:border-b-0">
|
||||
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.library') }}</dt>
|
||||
<dd class="truncate text-[13px] font-medium">{{ book.libraryName || '-' }}</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[6px] last:border-b-0">
|
||||
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.added') }}</dt>
|
||||
<template v-if="editingAddedDate">
|
||||
<dd class="mt-1">
|
||||
@@ -2006,7 +2006,7 @@ watch(
|
||||
</div>
|
||||
<HardcoverBookSyncGridItem :book-id="book.id" />
|
||||
<StorygraphBookSyncGridItem :book-id="book.id" />
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[6px] last:border-b-0">
|
||||
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.dateStarted') }}</dt>
|
||||
<template v-if="isEditingReadingDate('startedAt')">
|
||||
<dd class="mt-1">
|
||||
@@ -2049,7 +2049,7 @@ watch(
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[6px] last:border-b-0">
|
||||
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.dateFinished') }}</dt>
|
||||
<template v-if="isEditingReadingDate('finishedAt')">
|
||||
<dd class="mt-1">
|
||||
|
||||
@@ -741,7 +741,11 @@ async function autoFill() {
|
||||
|
||||
const preview = result.metadata
|
||||
if (Object.keys(preview).length === 0) {
|
||||
toast.info(metadataRefreshEmptyMessage(result.diagnostics, props.book), { closeButton: true, duration: AUTO_FILL_EMPTY_TOAST_DURATION_MS })
|
||||
const message =
|
||||
result.diagnostics.reason === 'no_existing_provider_ids'
|
||||
? t('book.detail.editMetadata.noExistingProviderIds')
|
||||
: metadataRefreshEmptyMessage(result.diagnostics, props.book)
|
||||
toast.info(message, { closeButton: true, duration: AUTO_FILL_EMPTY_TOAST_DURATION_MS })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ function formatLabel(format: string | null): string {
|
||||
|
||||
<!-- A two-column grid rather than per-row flex, so every value lines up on the same
|
||||
edge no matter how long the label beside it is. -->
|
||||
<dl class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-3 px-2.5 py-3 text-xs">
|
||||
<dl class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-2 px-2.5 py-3 text-xs">
|
||||
<dt class="text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">{{ t('book.detail.editMetadata.libraryRow') }}</dt>
|
||||
<dd class="truncate text-right font-medium" :title="book.libraryName">{{ book.libraryName }}</dd>
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ const plainButtonClass = computed(() => buttonClass(false))
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
editor.value?.setEditable(!disabled)
|
||||
editor.value?.setEditable(!disabled, false)
|
||||
if (disabled) closeLinkPanel()
|
||||
},
|
||||
)
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import RichDescriptionEditor from '../RichDescriptionEditor.vue'
|
||||
|
||||
describe('RichDescriptionEditor', () => {
|
||||
it('does not emit a metadata change when temporarily disabled', async () => {
|
||||
const wrapper = mount(RichDescriptionEditor, {
|
||||
props: {
|
||||
modelValue: 'First paragraph.\n\nSecond paragraph.',
|
||||
disabled: false,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
TooltipContent: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.setProps({ disabled: true })
|
||||
await wrapper.setProps({ disabled: false })
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -66,6 +66,7 @@ function withBlocklist(prefs: MetadataFetchPreferences, blocklist: string[]): Me
|
||||
maxCount: prefs.options?.genres.maxCount ?? null,
|
||||
},
|
||||
saveProviderIds: prefs.options?.saveProviderIds ?? true,
|
||||
providerIdMode: prefs.options?.providerIdMode ?? 'preferExisting',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MetadataFetchOptions } from '@bookorbit/types'
|
||||
import AdvancedFetchOptions from '../components/AdvancedFetchOptions.vue'
|
||||
|
||||
function options(providerIdMode: MetadataFetchOptions['providerIdMode'] = 'preferExisting'): MetadataFetchOptions {
|
||||
return {
|
||||
genres: { mode: 'merge', blocklist: [], maxCount: null },
|
||||
saveProviderIds: true,
|
||||
providerIdMode,
|
||||
}
|
||||
}
|
||||
|
||||
function identityModeSwitch(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.get('button[aria-labelledby="existing-provider-ids-only-label"]')
|
||||
}
|
||||
|
||||
describe('AdvancedFetchOptions', () => {
|
||||
it('renders the existing-provider-id restriction with an accessible explanation', () => {
|
||||
const wrapper = mount(AdvancedFetchOptions, { props: { options: options() } })
|
||||
|
||||
expect(wrapper.text()).toContain('Use existing provider IDs only')
|
||||
expect(wrapper.text()).toContain('Manual search and new-book discovery are unaffected.')
|
||||
expect(identityModeSwitch(wrapper).attributes('role')).toBe('switch')
|
||||
expect(identityModeSwitch(wrapper).attributes('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('enables and disables existing-only mode without changing other advanced options', async () => {
|
||||
const initial = options()
|
||||
const wrapper = mount(AdvancedFetchOptions, { props: { options: initial } })
|
||||
|
||||
await identityModeSwitch(wrapper).trigger('click')
|
||||
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual({ ...initial, providerIdMode: 'existingOnly' })
|
||||
|
||||
await wrapper.setProps({ options: options('existingOnly') })
|
||||
expect(identityModeSwitch(wrapper).attributes('aria-checked')).toBe('true')
|
||||
await identityModeSwitch(wrapper).trigger('click')
|
||||
expect(wrapper.emitted('change')?.[1]?.[0]).toEqual({ ...initial, providerIdMode: 'preferExisting' })
|
||||
})
|
||||
|
||||
it('disables the new setting while preferences are being saved', () => {
|
||||
const wrapper = mount(AdvancedFetchOptions, { props: { options: options('existingOnly'), disabled: true } })
|
||||
|
||||
expect(identityModeSwitch(wrapper).attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
+10
-4
@@ -1,7 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MetadataFetchPreferences } from '@bookorbit/types'
|
||||
import type { MetadataFetchOptions, MetadataFetchPreferences } from '@bookorbit/types'
|
||||
import MetadataGenreBlocklistSettings from '../MetadataGenreBlocklistSettings.vue'
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json?: () => Promise<unknown> }>>())
|
||||
@@ -34,7 +34,11 @@ function response(data: unknown, ok = true) {
|
||||
}
|
||||
}
|
||||
|
||||
function makePrefs(blocklist: string[], marker = 'initial'): MetadataFetchPreferences {
|
||||
function makePrefs(
|
||||
blocklist: string[],
|
||||
marker = 'initial',
|
||||
providerIdMode: MetadataFetchOptions['providerIdMode'] = 'preferExisting',
|
||||
): MetadataFetchPreferences {
|
||||
return {
|
||||
fields: {
|
||||
title: {
|
||||
@@ -50,6 +54,7 @@ function makePrefs(blocklist: string[], marker = 'initial'): MetadataFetchPrefer
|
||||
maxCount: 3,
|
||||
},
|
||||
saveProviderIds: true,
|
||||
providerIdMode,
|
||||
},
|
||||
} as unknown as MetadataFetchPreferences
|
||||
}
|
||||
@@ -72,7 +77,7 @@ describe('MetadataGenreBlocklistSettings', () => {
|
||||
.mockResolvedValueOnce(response(makePrefs(['Audiobook'])))
|
||||
.mockReturnValueOnce(latestFetch.promise)
|
||||
.mockResolvedValueOnce(response({}))
|
||||
.mockResolvedValueOnce(response(makePrefs(['Audiobook', 'Adult'], 'latest')))
|
||||
.mockResolvedValueOnce(response(makePrefs(['Audiobook', 'Adult'], 'latest', 'existingOnly')))
|
||||
|
||||
const wrapper = mount(MetadataGenreBlocklistSettings)
|
||||
await flushPromises()
|
||||
@@ -85,7 +90,7 @@ describe('MetadataGenreBlocklistSettings', () => {
|
||||
expect(input.element.value).toBe('')
|
||||
expect(wrapper.text()).not.toContain('This genre is already blocked.')
|
||||
|
||||
latestFetch.resolve(response(makePrefs(['Audiobook'], 'latest')))
|
||||
latestFetch.resolve(response(makePrefs(['Audiobook'], 'latest', 'existingOnly')))
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMock).toHaveBeenCalledTimes(4)
|
||||
@@ -94,6 +99,7 @@ describe('MetadataGenreBlocklistSettings', () => {
|
||||
expect(body.fields.title.providers).toEqual(['latest'])
|
||||
expect(body.options?.genres.blocklist).toEqual(['Audiobook', 'Adult'])
|
||||
expect(body.options?.genres.maxCount).toBe(3)
|
||||
expect(body.options?.providerIdMode).toBe('existingOnly')
|
||||
})
|
||||
|
||||
it('does not submit duplicate genre values', async () => {
|
||||
|
||||
+23
-1
@@ -28,6 +28,10 @@ function setSaveProviderIds(value: boolean) {
|
||||
emit('change', { ...props.options, saveProviderIds: value })
|
||||
}
|
||||
|
||||
function setExistingProviderIdsOnly(value: boolean) {
|
||||
emit('change', { ...props.options, providerIdMode: value ? 'existingOnly' : 'preferExisting' })
|
||||
}
|
||||
|
||||
function setMaxCount(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).valueAsNumber
|
||||
emit('change', { ...props.options, genres: { ...props.options.genres, maxCount: Number.isNaN(value) ? null : value } })
|
||||
@@ -41,7 +45,7 @@ function setMaxCount(event: Event) {
|
||||
<h3 class="settings-group-label mb-0!">{{ t('settings.metadata.fieldRules.advanced.title') }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="h-full rounded-lg border border-border bg-background/60 p-4 shadow-xs">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<span id="combine-genres-label" class="text-sm font-medium leading-5 text-foreground">
|
||||
@@ -105,6 +109,24 @@ function setMaxCount(event: Event) {
|
||||
{{ t('settings.metadata.fieldRules.advanced.storeProviderIds.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="h-full rounded-lg border border-border bg-background/60 p-4 shadow-xs">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<span id="existing-provider-ids-only-label" class="text-sm font-medium leading-5 text-foreground">
|
||||
{{ t('settings.metadata.fieldRules.advanced.existingProviderIdsOnly.label') }}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
:model-value="options.providerIdMode === 'existingOnly'"
|
||||
:disabled="disabled"
|
||||
aria-labelledby="existing-provider-ids-only-label"
|
||||
aria-describedby="existing-provider-ids-only-hint"
|
||||
@update:model-value="setExistingProviderIdsOnly"
|
||||
/>
|
||||
</div>
|
||||
<p id="existing-provider-ids-only-hint" class="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{{ t('settings.metadata.fieldRules.advanced.existingProviderIdsOnly.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
+16
-3
@@ -15,7 +15,10 @@ function makePrefs(providers: MetadataProviderKey[] = [key('goodreads'), key('go
|
||||
},
|
||||
{} as Record<MetadataField, FieldPreference>,
|
||||
)
|
||||
return { fields, options: { genres: { mode: 'merge', blocklist: [], maxCount: null }, saveProviderIds: true } }
|
||||
return {
|
||||
fields,
|
||||
options: { genres: { mode: 'merge', blocklist: [], maxCount: null }, saveProviderIds: true, providerIdMode: 'preferExisting' },
|
||||
}
|
||||
}
|
||||
|
||||
const LIBRARIES = [
|
||||
@@ -234,7 +237,11 @@ describe('useFieldRuleScopes', () => {
|
||||
|
||||
it('discards a changed advanced option back to the stored value', async () => {
|
||||
const { api } = await setup()
|
||||
api.setOptions({ genres: { mode: 'firstProvider', blocklist: [], maxCount: 999 }, saveProviderIds: false })
|
||||
api.setOptions({
|
||||
genres: { mode: 'firstProvider', blocklist: [], maxCount: 999 },
|
||||
saveProviderIds: false,
|
||||
providerIdMode: 'existingOnly',
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
api.discard()
|
||||
@@ -243,13 +250,18 @@ describe('useFieldRuleScopes', () => {
|
||||
expect(api.isDirty.value).toBe(false)
|
||||
expect(api.optionsDraft.value?.genres.maxCount).toBeNull()
|
||||
expect(api.optionsDraft.value?.saveProviderIds).toBe(true)
|
||||
expect(api.optionsDraft.value?.providerIdMode).toBe('preferExisting')
|
||||
})
|
||||
|
||||
it('counts a changed advanced option as unsaved on the global scope', async () => {
|
||||
const { api } = await setup()
|
||||
expect(api.isDirty.value).toBe(false)
|
||||
|
||||
api.setOptions({ genres: { mode: 'firstProvider', blocklist: [], maxCount: 12 }, saveProviderIds: false })
|
||||
api.setOptions({
|
||||
genres: { mode: 'firstProvider', blocklist: [], maxCount: 12 },
|
||||
saveProviderIds: false,
|
||||
providerIdMode: 'existingOnly',
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(api.isDirty.value).toBe(true)
|
||||
@@ -257,5 +269,6 @@ describe('useFieldRuleScopes', () => {
|
||||
const put = requests.find((request) => request.method === 'PUT')
|
||||
const body = put?.body as MetadataFetchPreferences
|
||||
expect(body.options?.genres.maxCount).toBe(12)
|
||||
expect(body.options?.providerIdMode).toBe('existingOnly')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -99,6 +99,7 @@ export function useFieldRuleScopes() {
|
||||
maxCount: source.options?.genres.maxCount ?? null,
|
||||
},
|
||||
saveProviderIds: source.options?.saveProviderIds ?? true,
|
||||
providerIdMode: source.options?.providerIdMode ?? 'preferExisting',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1634,6 +1634,10 @@
|
||||
"storeProviderIds": {
|
||||
"label": "Store provider IDs on books",
|
||||
"hint": "When fetching metadata, save the returned provider IDs (ISBN, ASIN, Goodreads ID, etc.) on the book record for more accurate future lookups."
|
||||
},
|
||||
"existingProviderIdsOnly": {
|
||||
"label": "Use existing provider IDs only",
|
||||
"hint": "For books already in the library, query only providers with a stored ID and never fall back to a search when an ID lookup fails. Manual search and new-book discovery are unaffected."
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
@@ -5094,6 +5098,7 @@
|
||||
"writeToFile": "Write to file",
|
||||
"autoFill": "Auto-fill",
|
||||
"fetchingMetadata": "Fetching metadata...",
|
||||
"noExistingProviderIds": "No metadata fetched: none of the active Field Rule providers has an ID stored on this book.",
|
||||
"allFieldsLocked": "All fields are locked",
|
||||
"autoFillTooltip": "Auto-fill fields using your metadata preferences",
|
||||
"lockAll": "Lock all",
|
||||
|
||||
@@ -117,7 +117,8 @@ export interface MetadataProviderSearchStatus {
|
||||
outcome: MetadataProviderSearchOutcome;
|
||||
}
|
||||
|
||||
export type MetadataFetchEmptyReason = "no_active_providers" | "providers_throttled" | "no_candidates" | "no_resolved_fields";
|
||||
export type MetadataFetchEmptyReason =
|
||||
"no_active_providers" | "no_existing_provider_ids" | "providers_throttled" | "no_candidates" | "no_resolved_fields";
|
||||
|
||||
export interface MetadataFetchDiagnostics {
|
||||
reason: MetadataFetchEmptyReason | null;
|
||||
|
||||
@@ -40,9 +40,11 @@ export const ALL_METADATA_FIELDS: MetadataField[] = [
|
||||
export type MergeStrategy = "fillMissing" | "overwrite" | "overwriteIfProvided";
|
||||
export type MetadataMergeStrategy = MergeStrategy | "mergeExisting";
|
||||
export type GenreMergeMode = "firstProvider" | "merge";
|
||||
export type ProviderIdFetchMode = "preferExisting" | "existingOnly";
|
||||
export const MERGE_STRATEGIES: MergeStrategy[] = ["fillMissing", "overwrite", "overwriteIfProvided"];
|
||||
export const GENRE_MERGE_STRATEGIES: MetadataMergeStrategy[] = ["fillMissing", "mergeExisting", "overwriteIfProvided", "overwrite"];
|
||||
export const GENRE_MERGE_MODES: GenreMergeMode[] = ["firstProvider", "merge"];
|
||||
export const PROVIDER_ID_FETCH_MODES: ProviderIdFetchMode[] = ["preferExisting", "existingOnly"];
|
||||
export const MAX_METADATA_GENRE_COUNT = 50;
|
||||
|
||||
export interface FieldPreference {
|
||||
@@ -60,6 +62,7 @@ export interface MetadataFetchOptions {
|
||||
maxCount: number | null;
|
||||
};
|
||||
saveProviderIds: boolean;
|
||||
providerIdMode: ProviderIdFetchMode;
|
||||
}
|
||||
|
||||
export interface MetadataFetchPreferences {
|
||||
|
||||
@@ -125,6 +125,95 @@ describe('MetadataFetchPipeline', () => {
|
||||
expect(fetchService.searchCandidates).toHaveBeenCalledWith({ title: 'Query' }, [MetadataProviderKey.GOOGLE, MetadataProviderKey.OPEN_LIBRARY]);
|
||||
});
|
||||
|
||||
it('restricts established-book refreshes to providers with stored ids in existing-only mode', async () => {
|
||||
const preferences = createPreferences();
|
||||
preferences.options = {
|
||||
genres: { mode: 'merge', blocklist: [], maxCount: null },
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'existingOnly',
|
||||
};
|
||||
preferencesService.getGlobal.mockResolvedValue(preferences);
|
||||
resolver.resolve.mockReturnValue(preferences);
|
||||
resolver.withForwardCompatibility.mockReturnValue(preferences);
|
||||
registry.all.mockReturnValue([{ key: MetadataProviderKey.GOOGLE }, { key: MetadataProviderKey.OPEN_LIBRARY }] as never);
|
||||
fetchService.searchCandidates.mockReturnValue(of(candidate(MetadataProviderKey.GOOGLE, 'stored-google', { title: 'Fetched Title' })));
|
||||
|
||||
const params = {
|
||||
title: 'Query',
|
||||
existingProviderIds: { [MetadataProviderKey.GOOGLE]: 'stored-google' },
|
||||
};
|
||||
const { resolved } = await pipeline.runWithSources(params, {});
|
||||
|
||||
expect(resolved.title).toBe('Fetched Title');
|
||||
expect(fetchService.searchCandidates).toHaveBeenCalledWith({ ...params, existingProviderIdsOnly: true }, [MetadataProviderKey.GOOGLE]);
|
||||
});
|
||||
|
||||
it('reports when existing-only mode has no stored ids for active field-rule providers', async () => {
|
||||
const preferences = createPreferences();
|
||||
preferences.options = {
|
||||
genres: { mode: 'merge', blocklist: [], maxCount: null },
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'existingOnly',
|
||||
};
|
||||
preferencesService.getGlobal.mockResolvedValue(preferences);
|
||||
resolver.resolve.mockReturnValue(preferences);
|
||||
resolver.withForwardCompatibility.mockReturnValue(preferences);
|
||||
registry.all.mockReturnValue([{ key: MetadataProviderKey.GOOGLE }, { key: MetadataProviderKey.OPEN_LIBRARY }] as never);
|
||||
|
||||
const { diagnostics, resolved } = await pipeline.runWithSources({ title: 'Query', existingProviderIds: {} }, {});
|
||||
|
||||
expect(resolved).toEqual({});
|
||||
expect(diagnostics.reason).toBe('no_existing_provider_ids');
|
||||
expect(diagnostics.activeProviders).toEqual([]);
|
||||
expect(fetchService.searchCandidates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets AudNexus refresh through an existing Audible id in existing-only mode', async () => {
|
||||
const preferences = createPreferences((fields) => {
|
||||
for (const field of ALL_METADATA_FIELDS) fields[field].providers = [MetadataProviderKey.AUDNEXUS];
|
||||
});
|
||||
preferences.options = {
|
||||
genres: { mode: 'merge', blocklist: [], maxCount: null },
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'existingOnly',
|
||||
};
|
||||
preferencesService.getGlobal.mockResolvedValue(preferences);
|
||||
resolver.resolve.mockReturnValue(preferences);
|
||||
resolver.withForwardCompatibility.mockReturnValue(preferences);
|
||||
registry.all.mockReturnValue([{ key: MetadataProviderKey.AUDNEXUS }] as never);
|
||||
fetchService.searchCandidates.mockReturnValue(of(candidate(MetadataProviderKey.AUDNEXUS, 'B0EXISTING', { title: 'Audio Title' })));
|
||||
|
||||
const params = {
|
||||
title: 'Audio Title',
|
||||
existingProviderIds: { [MetadataProviderKey.AUDIBLE]: 'B0EXISTING' },
|
||||
isAudiobook: true,
|
||||
};
|
||||
await pipeline.run(params, {});
|
||||
|
||||
expect(fetchService.searchCandidates).toHaveBeenCalledWith({ ...params, existingProviderIdsOnly: true, includeAudiobookProviders: true }, [
|
||||
MetadataProviderKey.AUDNEXUS,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps identity discovery enabled when strict preferences are used outside an established-book refresh', async () => {
|
||||
const preferences = createPreferences();
|
||||
preferences.options = {
|
||||
genres: { mode: 'merge', blocklist: [], maxCount: null },
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'existingOnly',
|
||||
};
|
||||
preferencesService.getGlobal.mockResolvedValue(preferences);
|
||||
resolver.resolve.mockReturnValue(preferences);
|
||||
resolver.withForwardCompatibility.mockReturnValue(preferences);
|
||||
registry.all.mockReturnValue([{ key: MetadataProviderKey.GOOGLE }, { key: MetadataProviderKey.OPEN_LIBRARY }] as never);
|
||||
fetchService.searchCandidates.mockReturnValue(of(candidate(MetadataProviderKey.GOOGLE, 'new-google', { title: 'Discovered Title' })));
|
||||
|
||||
const resolved = await pipeline.run({ title: 'Query' }, {});
|
||||
|
||||
expect(resolved.title).toBe('Discovered Title');
|
||||
expect(fetchService.searchCandidates).toHaveBeenCalledWith({ title: 'Query' }, [MetadataProviderKey.GOOGLE, MetadataProviderKey.OPEN_LIBRARY]);
|
||||
});
|
||||
|
||||
describe('series expected counts', () => {
|
||||
function primePreferences() {
|
||||
const global = createPreferences();
|
||||
@@ -510,7 +599,11 @@ describe('MetadataFetchPipeline', () => {
|
||||
mergeStrategy: 'overwriteIfProvided',
|
||||
};
|
||||
});
|
||||
prefs.options = { genres: { mode: 'firstProvider', blocklist: [], maxCount: null }, saveProviderIds: true };
|
||||
prefs.options = {
|
||||
genres: { mode: 'firstProvider', blocklist: [], maxCount: null },
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
resolver.resolve.mockReturnValue(prefs);
|
||||
resolver.withForwardCompatibility.mockReturnValue(prefs);
|
||||
@@ -959,6 +1052,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
prefs.options = {
|
||||
genres: { mode: 'merge', blocklist: [], maxCount: null },
|
||||
saveProviderIds: false,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
@@ -989,6 +1083,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
prefs.options = {
|
||||
genres: { mode: 'merge', blocklist: [], maxCount: null },
|
||||
saveProviderIds: false,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
@@ -1022,6 +1117,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
prefs.options = {
|
||||
genres: { mode: 'firstProvider', blocklist: ['Adult'], maxCount: 3 },
|
||||
saveProviderIds: false,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
@@ -1048,6 +1144,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
prefs.options = {
|
||||
genres: { mode: 'firstProvider', blocklist: [], maxCount: null },
|
||||
saveProviderIds: false,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
@@ -1073,6 +1170,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
prefs.options = {
|
||||
genres: { mode: 'firstProvider', blocklist: [], maxCount: 2 },
|
||||
saveProviderIds: false,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
@@ -1097,6 +1195,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
prefs.options = {
|
||||
genres: { mode: 'merge', blocklist: ['audiobook', ' Adult '], maxCount: null },
|
||||
saveProviderIds: false,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
@@ -1127,6 +1226,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
prefs.options = {
|
||||
genres: { mode: 'merge', blocklist: ['Adult'], maxCount: 3 },
|
||||
saveProviderIds: false,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
@@ -1157,6 +1257,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
prefs.options = {
|
||||
genres: { mode: 'firstProvider', blocklist: ['Audiobook'], maxCount: null },
|
||||
saveProviderIds: false,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
@@ -1187,6 +1288,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
prefs.options = {
|
||||
genres: { mode: 'firstProvider', blocklist: [], maxCount: 2 },
|
||||
saveProviderIds: false,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
@@ -1217,6 +1319,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
prefs.options = {
|
||||
genres: { mode: 'firstProvider', blocklist: [], maxCount: null },
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
@@ -1242,6 +1345,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
prefs.options = {
|
||||
genres: { mode: 'firstProvider', blocklist: [], maxCount: null },
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
@@ -1273,6 +1377,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
prefs.options = {
|
||||
genres: { mode: 'firstProvider', blocklist: [], maxCount: null },
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
@@ -1300,6 +1405,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
prefs.options = {
|
||||
genres: { mode: 'firstProvider', blocklist: [], maxCount: null },
|
||||
saveProviderIds: false,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
@@ -1324,6 +1430,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
prefs.options = {
|
||||
genres: { mode: 'firstProvider', blocklist: [], maxCount: null },
|
||||
saveProviderIds: false,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
|
||||
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||
|
||||
@@ -59,7 +59,9 @@ const AUDIOBOOK_PROVIDER_KEYS = new Set<MetadataProviderKey>([
|
||||
type ProviderSelectionDiagnostics = Pick<
|
||||
MetadataFetchDiagnostics,
|
||||
'activeProviders' | 'fieldRuleProviders' | 'disabledFieldRuleProviders' | 'enabledUnreferencedProviders' | 'throttledProviders'
|
||||
>;
|
||||
> & {
|
||||
missingExistingProviderIdCount: number;
|
||||
};
|
||||
|
||||
type ConfiguredProviderSelection = Pick<
|
||||
MetadataFetchDiagnostics,
|
||||
@@ -129,10 +131,17 @@ export class MetadataFetchPipeline {
|
||||
diagnostics: MetadataFetchDiagnostics;
|
||||
}> {
|
||||
const { preferences, registeredKeys, providerConfig } = await this.resolveProviderPreferenceContext(libraryId);
|
||||
const providerSelection = this.deriveProviderSet(preferences, registeredKeys, providerConfig);
|
||||
const existingProviderIdsOnly = preferences.options?.providerIdMode === 'existingOnly' && params.existingProviderIds !== undefined;
|
||||
const providerSelection = this.deriveProviderSet(
|
||||
preferences,
|
||||
registeredKeys,
|
||||
providerConfig,
|
||||
existingProviderIdsOnly ? params.existingProviderIds : undefined,
|
||||
);
|
||||
const providerSearchParams = existingProviderIdsOnly ? { ...params, existingProviderIdsOnly: true } : params;
|
||||
const searchParams = providerSelection.activeProviders.some((provider) => AUDIOBOOK_PROVIDER_KEYS.has(provider))
|
||||
? { ...params, includeAudiobookProviders: true }
|
||||
: params;
|
||||
? { ...providerSearchParams, includeAudiobookProviders: true }
|
||||
: providerSearchParams;
|
||||
const candidates = providerSelection.activeProviders.length
|
||||
? await firstValueFrom(this.fetchService.searchCandidates(searchParams, providerSelection.activeProviders).pipe(toArray()), {
|
||||
defaultValue: [] as MetadataCandidate[],
|
||||
@@ -252,12 +261,18 @@ export class MetadataFetchPipeline {
|
||||
preferences: MetadataFetchPreferences,
|
||||
registeredKeys: MetadataProviderKey[],
|
||||
providerConfig: ProviderConfigurations,
|
||||
requiredProviderIds?: Partial<Record<MetadataProviderKey, string>>,
|
||||
): ProviderSelectionDiagnostics {
|
||||
const configuredProviderSelection = this.deriveConfiguredProviderSelection(preferences, registeredKeys, providerConfig);
|
||||
|
||||
const activeProviders: MetadataProviderKey[] = [];
|
||||
const throttledProviders: MetadataProviderKey[] = [];
|
||||
let missingExistingProviderIdCount = 0;
|
||||
for (const key of configuredProviderSelection.configuredFieldRuleProviders) {
|
||||
if (requiredProviderIds && !this.hasExistingProviderIdentity(key, requiredProviderIds)) {
|
||||
missingExistingProviderIdCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (this.throttleTracker.isThrottled(key)) {
|
||||
throttledProviders.push(key);
|
||||
this.logger.warn(
|
||||
@@ -274,9 +289,15 @@ export class MetadataFetchPipeline {
|
||||
disabledFieldRuleProviders: configuredProviderSelection.disabledFieldRuleProviders,
|
||||
enabledUnreferencedProviders: configuredProviderSelection.enabledUnreferencedProviders,
|
||||
throttledProviders,
|
||||
missingExistingProviderIdCount,
|
||||
};
|
||||
}
|
||||
|
||||
private hasExistingProviderIdentity(provider: MetadataProviderKey, providerIds: Partial<Record<MetadataProviderKey, string>>): boolean {
|
||||
const identityProvider = provider === MetadataProviderKey.AUDNEXUS ? MetadataProviderKey.AUDIBLE : provider;
|
||||
return Boolean(providerIds[identityProvider]);
|
||||
}
|
||||
|
||||
private buildDiagnostics(
|
||||
providerSelection: ProviderSelectionDiagnostics,
|
||||
candidates: MetadataCandidate[],
|
||||
@@ -284,11 +305,14 @@ export class MetadataFetchPipeline {
|
||||
): MetadataFetchDiagnostics {
|
||||
const candidateProviders = [...new Set(candidates.map((candidate) => candidate.provider))];
|
||||
const resolvedFieldCount = Object.keys(resolved).length;
|
||||
const { missingExistingProviderIdCount, ...diagnosticProviderSelection } = providerSelection;
|
||||
|
||||
let reason: MetadataFetchDiagnostics['reason'] = null;
|
||||
if (resolvedFieldCount === 0) {
|
||||
if (providerSelection.activeProviders.length === 0) {
|
||||
reason = providerSelection.throttledProviders.length > 0 ? 'providers_throttled' : 'no_active_providers';
|
||||
if (providerSelection.throttledProviders.length > 0) reason = 'providers_throttled';
|
||||
else if (missingExistingProviderIdCount > 0) reason = 'no_existing_provider_ids';
|
||||
else reason = 'no_active_providers';
|
||||
} else if (candidates.length === 0) {
|
||||
reason = 'no_candidates';
|
||||
} else {
|
||||
@@ -297,7 +321,7 @@ export class MetadataFetchPipeline {
|
||||
}
|
||||
|
||||
return {
|
||||
...providerSelection,
|
||||
...diagnosticProviderSelection,
|
||||
candidateProviders,
|
||||
candidateCount: candidates.length,
|
||||
resolvedFieldCount,
|
||||
|
||||
@@ -421,6 +421,96 @@ describe('MetadataFetchService', () => {
|
||||
expect(google.search).toHaveBeenCalledWith(expect.objectContaining({ title: 'Dune', author: 'Frank Herbert' }));
|
||||
});
|
||||
|
||||
it('does not search for a replacement when an exact lookup returns null in existing-only mode', async () => {
|
||||
const google: IdentifiableProvider = {
|
||||
key: MetadataProviderKey.GOOGLE,
|
||||
label: 'Google',
|
||||
identifiable: true,
|
||||
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.GOOGLE, 'replacement-id', 'Dune')]),
|
||||
lookupById: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
registry.select.mockReturnValue([google]);
|
||||
|
||||
const results = await firstValueFrom(
|
||||
service
|
||||
.search({
|
||||
title: 'Dune',
|
||||
existingProviderIds: { [MetadataProviderKey.GOOGLE]: 'stored-id' },
|
||||
existingProviderIdsOnly: true,
|
||||
})
|
||||
.pipe(candidatesOnly(), toArray()),
|
||||
);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(google.lookupById).toHaveBeenCalledOnce();
|
||||
expect(google.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not search for a replacement when an exact lookup fails relevance in existing-only mode', async () => {
|
||||
const google: IdentifiableProvider = {
|
||||
key: MetadataProviderKey.GOOGLE,
|
||||
label: 'Google',
|
||||
identifiable: true,
|
||||
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.GOOGLE, 'replacement-id', 'Dune')]),
|
||||
lookupById: vi.fn().mockResolvedValue(candidate(MetadataProviderKey.GOOGLE, 'stored-id', 'Completely Unrelated')),
|
||||
};
|
||||
registry.select.mockReturnValue([google]);
|
||||
|
||||
const results = await firstValueFrom(
|
||||
service
|
||||
.search({
|
||||
title: 'Dune',
|
||||
author: 'Frank Herbert',
|
||||
existingProviderIds: { [MetadataProviderKey.GOOGLE]: 'stored-id' },
|
||||
existingProviderIdsOnly: true,
|
||||
})
|
||||
.pipe(candidatesOnly(), toArray()),
|
||||
);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(google.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not query a provider without a stored identity in existing-only mode', async () => {
|
||||
const google: IdentifiableProvider = {
|
||||
key: MetadataProviderKey.GOOGLE,
|
||||
label: 'Google',
|
||||
identifiable: true,
|
||||
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.GOOGLE, 'new-id', 'Dune')]),
|
||||
lookupById: vi.fn(),
|
||||
};
|
||||
registry.select.mockReturnValue([google]);
|
||||
|
||||
const results = await firstValueFrom(
|
||||
service.search({ title: 'Dune', existingProviderIds: {}, existingProviderIdsOnly: true }).pipe(candidatesOnly(), toArray()),
|
||||
);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(google.lookupById).not.toHaveBeenCalled();
|
||||
expect(google.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows AudNexus to use an existing Audible identity without discovery fallbacks', async () => {
|
||||
const audnexus: MetadataProvider = {
|
||||
key: MetadataProviderKey.AUDNEXUS,
|
||||
label: 'AudNexus',
|
||||
identifiable: false,
|
||||
search: vi.fn().mockResolvedValue([candidate(MetadataProviderKey.AUDNEXUS, 'B0EXISTING', 'Dune')]),
|
||||
};
|
||||
registry.select.mockReturnValue([audnexus]);
|
||||
|
||||
const params = {
|
||||
title: 'Dune',
|
||||
existingProviderIds: { [MetadataProviderKey.AUDIBLE]: 'B0EXISTING' },
|
||||
existingProviderIdsOnly: true,
|
||||
};
|
||||
const results = await firstValueFrom(service.search(params).pipe(candidatesOnly(), toArray()));
|
||||
|
||||
expect(results).toEqual([candidate(MetadataProviderKey.AUDNEXUS, 'B0EXISTING', 'Dune')]);
|
||||
expect(audnexus.search).toHaveBeenCalledOnce();
|
||||
expect(audnexus.search).toHaveBeenCalledWith(expect.objectContaining(params));
|
||||
});
|
||||
|
||||
it('isolates provider failures so one provider error does not fail the full stream', async () => {
|
||||
const failing: MetadataProvider = {
|
||||
key: MetadataProviderKey.GOODREADS,
|
||||
|
||||
@@ -176,6 +176,12 @@ export class MetadataFetchService {
|
||||
const rankedLookup = filterAndRank([lookupResult], params, 1);
|
||||
if (rankedLookup.length > 0) return rankedLookup;
|
||||
}
|
||||
if (params.existingProviderIdsOnly) return [];
|
||||
}
|
||||
|
||||
if (params.existingProviderIdsOnly) {
|
||||
if (provider.key !== MetadataProviderKey.AUDNEXUS || !params.existingProviderIds?.[MetadataProviderKey.AUDIBLE]) return [];
|
||||
return filterAndRank(await provider.search(params), params, 1);
|
||||
}
|
||||
|
||||
return this.searchAndRankProvider(provider, params);
|
||||
|
||||
@@ -12,7 +12,12 @@ export interface MetadataSearchParams {
|
||||
// True when `title` is a query the caller typed rather than the book's own stored title. Stored
|
||||
// series context may then fill in what the query omits, but must not override what it states.
|
||||
titleIsExplicitQuery?: boolean;
|
||||
// Presence, including an empty map, identifies a refresh of an established book. Discovery
|
||||
// flows omit this property because they have no stored provider identity to preserve.
|
||||
existingProviderIds?: Partial<Record<MetadataProviderKey, string>>;
|
||||
// Restricts refreshes to exact stored identities. Providers must not fall back to search when an
|
||||
// ID lookup returns no usable candidate.
|
||||
existingProviderIdsOnly?: boolean;
|
||||
// Pins a Hardcover refresh to a previously chosen edition instead of re-deriving one by ISBN.
|
||||
hardcoverEditionId?: string;
|
||||
// Media type of the edition being searched. Providers that carry both editions of a title use it to
|
||||
|
||||
@@ -132,6 +132,7 @@ describe('UpdateGlobalPreferencesDto', () => {
|
||||
maxCount: 3,
|
||||
},
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'existingOnly',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -152,6 +153,7 @@ describe('UpdateGlobalPreferencesDto', () => {
|
||||
mode: 'merge',
|
||||
},
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'preferExisting',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -173,6 +175,7 @@ describe('UpdateGlobalPreferencesDto', () => {
|
||||
maxCount: null,
|
||||
},
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'preferExisting',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -196,6 +199,7 @@ describe('UpdateGlobalPreferencesDto', () => {
|
||||
options: {
|
||||
genres: { mode: 'merge', maxCount },
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'preferExisting',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -220,6 +224,7 @@ describe('UpdateGlobalPreferencesDto', () => {
|
||||
maxCount: 0,
|
||||
},
|
||||
saveProviderIds: 'yes',
|
||||
providerIdMode: 'unsupported',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -16,8 +16,15 @@ import {
|
||||
validateSync,
|
||||
} from 'class-validator';
|
||||
import { plainToInstance, Type } from 'class-transformer';
|
||||
import { ALL_METADATA_FIELDS, GENRE_MERGE_MODES, GENRE_MERGE_STRATEGIES, MAX_METADATA_GENRE_COUNT, MetadataProviderKey } from '@bookorbit/types';
|
||||
import type { GenreMergeMode, MetadataField, MetadataMergeStrategy } from '@bookorbit/types';
|
||||
import {
|
||||
ALL_METADATA_FIELDS,
|
||||
GENRE_MERGE_MODES,
|
||||
GENRE_MERGE_STRATEGIES,
|
||||
MAX_METADATA_GENRE_COUNT,
|
||||
MetadataProviderKey,
|
||||
PROVIDER_ID_FETCH_MODES,
|
||||
} from '@bookorbit/types';
|
||||
import type { GenreMergeMode, MetadataField, MetadataMergeStrategy, ProviderIdFetchMode } from '@bookorbit/types';
|
||||
const PROVIDER_KEYS = Object.values(MetadataProviderKey);
|
||||
|
||||
export class FieldPreferenceDto {
|
||||
@@ -58,6 +65,9 @@ export class MetadataFetchOptionsDto {
|
||||
|
||||
@IsBoolean()
|
||||
saveProviderIds!: boolean;
|
||||
|
||||
@IsIn(PROVIDER_ID_FETCH_MODES)
|
||||
providerIdMode!: ProviderIdFetchMode;
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'isFieldPreferencesMap', async: false })
|
||||
|
||||
@@ -78,6 +78,7 @@ describe('MetadataPreferenceResolver', () => {
|
||||
expect(defaults.options).toEqual({
|
||||
genres: { mode: 'merge', blocklist: [], maxCount: null },
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'preferExisting',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,6 +152,7 @@ describe('MetadataPreferenceResolver', () => {
|
||||
options: {
|
||||
genres: { mode: 'invalid', blocklist: [42], maxCount: 0 },
|
||||
saveProviderIds: 'yes',
|
||||
providerIdMode: 'unsupported',
|
||||
},
|
||||
} as unknown as MetadataFetchPreferences;
|
||||
|
||||
@@ -170,6 +172,7 @@ describe('MetadataPreferenceResolver', () => {
|
||||
maxCount: 3,
|
||||
},
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'preferExisting',
|
||||
},
|
||||
} as unknown as MetadataFetchPreferences;
|
||||
|
||||
@@ -184,14 +187,14 @@ describe('MetadataPreferenceResolver', () => {
|
||||
const missingLimit = resolver.resolve(
|
||||
{
|
||||
fields: defaults.fields,
|
||||
options: { genres: { mode: 'merge', blocklist: [] }, saveProviderIds: true },
|
||||
options: { genres: { mode: 'merge', blocklist: [] }, saveProviderIds: true, providerIdMode: 'preferExisting' },
|
||||
} as unknown as MetadataFetchPreferences,
|
||||
null,
|
||||
);
|
||||
const invalidLimit = resolver.resolve(
|
||||
{
|
||||
fields: defaults.fields,
|
||||
options: { genres: { mode: 'merge', blocklist: [], maxCount: 51 }, saveProviderIds: true },
|
||||
options: { genres: { mode: 'merge', blocklist: [], maxCount: 51 }, saveProviderIds: true, providerIdMode: 'preferExisting' },
|
||||
},
|
||||
null,
|
||||
);
|
||||
@@ -200,6 +203,30 @@ describe('MetadataPreferenceResolver', () => {
|
||||
expect(invalidLimit.options?.genres.maxCount).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves existing-only provider identity mode and defaults legacy preferences', () => {
|
||||
const defaults = resolver.getDefaultPreferences();
|
||||
const strict = resolver.resolve(
|
||||
{
|
||||
fields: defaults.fields,
|
||||
options: { ...defaults.options!, providerIdMode: 'existingOnly' },
|
||||
},
|
||||
null,
|
||||
);
|
||||
const legacy = resolver.resolve(
|
||||
{
|
||||
fields: defaults.fields,
|
||||
options: {
|
||||
genres: defaults.options!.genres,
|
||||
saveProviderIds: true,
|
||||
},
|
||||
} as unknown as MetadataFetchPreferences,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(strict.options?.providerIdMode).toBe('existingOnly');
|
||||
expect(legacy.options?.providerIdMode).toBe('preferExisting');
|
||||
});
|
||||
|
||||
it('preserves explicit provider selections when applying forward compatibility', () => {
|
||||
const defaults = resolver.getDefaultPreferences();
|
||||
const preferences: MetadataFetchPreferences = {
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
MetadataProviderKey,
|
||||
MERGE_STRATEGIES,
|
||||
MergeStrategy,
|
||||
PROVIDER_ID_FETCH_MODES,
|
||||
ProviderIdFetchMode,
|
||||
} from '@bookorbit/types';
|
||||
|
||||
import { normalizeGenreBlocklist } from '../../common/utils/genre-fetch-options.utils';
|
||||
@@ -30,6 +32,7 @@ const DEFAULT_MERGE_STRATEGY: MergeStrategy = 'overwriteIfProvided';
|
||||
const MERGE_STRATEGY_SET = new Set<MetadataMergeStrategy>(MERGE_STRATEGIES);
|
||||
const GENRE_MERGE_STRATEGY_SET = new Set<MetadataMergeStrategy>(GENRE_MERGE_STRATEGIES);
|
||||
const GENRE_MERGE_MODE_SET = new Set(GENRE_MERGE_MODES);
|
||||
const PROVIDER_ID_FETCH_MODE_SET = new Set(PROVIDER_ID_FETCH_MODES);
|
||||
|
||||
const PROVIDERS_WITH_ITUNES: MetadataProviderKey[] = [
|
||||
MetadataProviderKey.GOODREADS,
|
||||
@@ -82,6 +85,7 @@ export class MetadataPreferenceResolver {
|
||||
maxCount: null,
|
||||
},
|
||||
saveProviderIds: true,
|
||||
providerIdMode: 'preferExisting',
|
||||
};
|
||||
return { fields, options };
|
||||
}
|
||||
@@ -141,7 +145,11 @@ export class MetadataPreferenceResolver {
|
||||
|
||||
private normalizeOptions(value: unknown, fallback: MetadataFetchOptions): MetadataFetchOptions {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return { genres: { ...fallback.genres, blocklist: [...fallback.genres.blocklist] }, saveProviderIds: fallback.saveProviderIds };
|
||||
return {
|
||||
genres: { ...fallback.genres, blocklist: [...fallback.genres.blocklist] },
|
||||
saveProviderIds: fallback.saveProviderIds,
|
||||
providerIdMode: fallback.providerIdMode,
|
||||
};
|
||||
}
|
||||
|
||||
const candidate = value as Partial<MetadataFetchOptions>;
|
||||
@@ -154,10 +162,14 @@ export class MetadataPreferenceResolver {
|
||||
const blocklist = normalizeGenreBlocklist(genresCandidate.blocklist, fallback.genres.blocklist);
|
||||
const maxCount = this.normalizeGenreMaxCount(genresCandidate.maxCount, fallback.genres.maxCount);
|
||||
const saveProviderIds = typeof candidate.saveProviderIds === 'boolean' ? candidate.saveProviderIds : fallback.saveProviderIds;
|
||||
const providerIdMode = PROVIDER_ID_FETCH_MODE_SET.has(candidate.providerIdMode as ProviderIdFetchMode)
|
||||
? (candidate.providerIdMode as ProviderIdFetchMode)
|
||||
: fallback.providerIdMode;
|
||||
|
||||
return {
|
||||
genres: { mode, blocklist, maxCount },
|
||||
saveProviderIds,
|
||||
providerIdMode,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user