@@ -105,6 +109,24 @@ function setMaxCount(event: Event) {
{{ t('settings.metadata.fieldRules.advanced.storeProviderIds.hint') }}
+
+
+
+
+ {{ t('settings.metadata.fieldRules.advanced.existingProviderIdsOnly.label') }}
+
+
+
+
+ {{ t('settings.metadata.fieldRules.advanced.existingProviderIdsOnly.hint') }}
+
+
diff --git a/client/src/features/settings/metadata-preferences/composables/__tests__/useFieldRuleScopes.spec.ts b/client/src/features/settings/metadata-preferences/composables/__tests__/useFieldRuleScopes.spec.ts
index 3b272b18c..084e73a4f 100644
--- a/client/src/features/settings/metadata-preferences/composables/__tests__/useFieldRuleScopes.spec.ts
+++ b/client/src/features/settings/metadata-preferences/composables/__tests__/useFieldRuleScopes.spec.ts
@@ -15,7 +15,10 @@ function makePrefs(providers: MetadataProviderKey[] = [key('goodreads'), key('go
},
{} as Record
,
)
- 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')
})
})
diff --git a/client/src/features/settings/metadata-preferences/composables/useFieldRuleScopes.ts b/client/src/features/settings/metadata-preferences/composables/useFieldRuleScopes.ts
index 7856cdb13..d53695a4d 100644
--- a/client/src/features/settings/metadata-preferences/composables/useFieldRuleScopes.ts
+++ b/client/src/features/settings/metadata-preferences/composables/useFieldRuleScopes.ts
@@ -99,6 +99,7 @@ export function useFieldRuleScopes() {
maxCount: source.options?.genres.maxCount ?? null,
},
saveProviderIds: source.options?.saveProviderIds ?? true,
+ providerIdMode: source.options?.providerIdMode ?? 'preferExisting',
}
}
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index abfb485c6..fbba86fd8 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -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",
diff --git a/packages/types/src/metadata-fetch.ts b/packages/types/src/metadata-fetch.ts
index d7651a219..e43292c97 100644
--- a/packages/types/src/metadata-fetch.ts
+++ b/packages/types/src/metadata-fetch.ts
@@ -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;
diff --git a/packages/types/src/metadata-preferences.ts b/packages/types/src/metadata-preferences.ts
index 34a84138b..802f07a6e 100644
--- a/packages/types/src/metadata-preferences.ts
+++ b/packages/types/src/metadata-preferences.ts
@@ -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 {
diff --git a/server/src/modules/metadata-fetch/metadata-fetch-pipeline.test.ts b/server/src/modules/metadata-fetch/metadata-fetch-pipeline.test.ts
index ba5cce9e7..14e9a324d 100644
--- a/server/src/modules/metadata-fetch/metadata-fetch-pipeline.test.ts
+++ b/server/src/modules/metadata-fetch/metadata-fetch-pipeline.test.ts
@@ -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);
diff --git a/server/src/modules/metadata-fetch/metadata-fetch-pipeline.ts b/server/src/modules/metadata-fetch/metadata-fetch-pipeline.ts
index 96933231b..d591b6900 100644
--- a/server/src/modules/metadata-fetch/metadata-fetch-pipeline.ts
+++ b/server/src/modules/metadata-fetch/metadata-fetch-pipeline.ts
@@ -59,7 +59,9 @@ const AUDIOBOOK_PROVIDER_KEYS = new Set([
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>,
): 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>): 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,
diff --git a/server/src/modules/metadata-fetch/metadata-fetch.service.test.ts b/server/src/modules/metadata-fetch/metadata-fetch.service.test.ts
index fa55456bc..f56d2daf0 100644
--- a/server/src/modules/metadata-fetch/metadata-fetch.service.test.ts
+++ b/server/src/modules/metadata-fetch/metadata-fetch.service.test.ts
@@ -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,
diff --git a/server/src/modules/metadata-fetch/metadata-fetch.service.ts b/server/src/modules/metadata-fetch/metadata-fetch.service.ts
index 917c9faba..986fdfb66 100644
--- a/server/src/modules/metadata-fetch/metadata-fetch.service.ts
+++ b/server/src/modules/metadata-fetch/metadata-fetch.service.ts
@@ -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);
diff --git a/server/src/modules/metadata-fetch/providers/metadata-search-params.ts b/server/src/modules/metadata-fetch/providers/metadata-search-params.ts
index 5caf5bd76..9468ee34c 100644
--- a/server/src/modules/metadata-fetch/providers/metadata-search-params.ts
+++ b/server/src/modules/metadata-fetch/providers/metadata-search-params.ts
@@ -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>;
+ // 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
diff --git a/server/src/modules/metadata-preferences/dto/update-global-preferences.dto.test.ts b/server/src/modules/metadata-preferences/dto/update-global-preferences.dto.test.ts
index 03cc67c7d..4847a1dc4 100644
--- a/server/src/modules/metadata-preferences/dto/update-global-preferences.dto.test.ts
+++ b/server/src/modules/metadata-preferences/dto/update-global-preferences.dto.test.ts
@@ -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',
},
});
diff --git a/server/src/modules/metadata-preferences/dto/update-global-preferences.dto.ts b/server/src/modules/metadata-preferences/dto/update-global-preferences.dto.ts
index bc17019cc..c6268f9b1 100644
--- a/server/src/modules/metadata-preferences/dto/update-global-preferences.dto.ts
+++ b/server/src/modules/metadata-preferences/dto/update-global-preferences.dto.ts
@@ -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 })
diff --git a/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts b/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts
index b426ad41e..2ca57cacc 100644
--- a/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts
+++ b/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts
@@ -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 = {
diff --git a/server/src/modules/metadata-preferences/metadata-preference-resolver.ts b/server/src/modules/metadata-preferences/metadata-preference-resolver.ts
index 63c3cb8b1..2c2519727 100644
--- a/server/src/modules/metadata-preferences/metadata-preference-resolver.ts
+++ b/server/src/modules/metadata-preferences/metadata-preference-resolver.ts
@@ -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(MERGE_STRATEGIES);
const GENRE_MERGE_STRATEGY_SET = new Set(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;
@@ -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,
};
}