feat(metadata): limit fetched genres per book

Keep provider metadata from creating overly granular genre catalogs while preserving unlimited behavior by default.

Closes #710
This commit is contained in:
neon
2026-08-10 21:25:37 +00:00
parent e56d253dc1
commit 2b215141a9
16 changed files with 457 additions and 123 deletions
@@ -1,48 +0,0 @@
export function normalizeGenreBlocklist(value: unknown, fallback: readonly string[] = []): string[] {
if (!Array.isArray(value)) return [...fallback];
const normalized: string[] = [];
const seen = new Set<string>();
for (const entry of value) {
if (typeof entry !== 'string') continue;
const trimmed = entry.trim();
if (!trimmed) continue;
const token = trimmed.toLowerCase();
if (seen.has(token)) continue;
seen.add(token);
normalized.push(trimmed);
}
return normalized;
}
export function createGenreBlocklistTokenSet(blocklist: readonly string[] | undefined): Set<string> {
return new Set(normalizeGenreBlocklist(blocklist).map((genre) => genre.toLowerCase()));
}
export function filterGenresAgainstBlocklist(genres: readonly string[] | undefined, blockedTokens: ReadonlySet<string>): string[] {
if (!genres?.length) return [];
const filtered: string[] = [];
for (const raw of genres) {
const genre = raw.trim();
if (!genre) continue;
if (blockedTokens.has(genre.toLowerCase())) continue;
filtered.push(genre);
}
return filtered;
}
export function filterCandidateGenresAgainstBlocklist<T extends { genres?: string[] }>(candidate: T, blockedTokens: ReadonlySet<string>): T {
if (!candidate.genres?.length || blockedTokens.size === 0) return candidate;
const filtered = filterGenresAgainstBlocklist(candidate.genres, blockedTokens);
if (filtered.length === candidate.genres.length && filtered.every((genre, index) => genre === candidate.genres?.[index])) {
return candidate;
}
return {
...candidate,
genres: filtered.length ? filtered : undefined,
};
}
@@ -0,0 +1,60 @@
export function normalizeGenreBlocklist(value: unknown, fallback: readonly string[] = []): string[] {
if (!Array.isArray(value)) return [...fallback];
const normalized: string[] = [];
const seen = new Set<string>();
for (const entry of value) {
if (typeof entry !== 'string') continue;
const trimmed = entry.trim();
if (!trimmed) continue;
const token = trimmed.toLowerCase();
if (seen.has(token)) continue;
seen.add(token);
normalized.push(trimmed);
}
return normalized;
}
export function createGenreBlocklistTokenSet(blocklist: readonly string[] | undefined): Set<string> {
return new Set(normalizeGenreBlocklist(blocklist).map((genre) => genre.toLowerCase()));
}
export function applyGenreFetchOptions(
genres: readonly string[] | undefined,
blockedTokens: ReadonlySet<string>,
maxCount: number | null | undefined,
): string[] {
if (!genres?.length) return [];
const resolved: string[] = [];
const seen = new Set<string>();
for (const raw of genres) {
const genre = raw.trim();
const token = genre.toLowerCase();
if (!genre || blockedTokens.has(token) || seen.has(token)) continue;
seen.add(token);
resolved.push(genre);
if (maxCount !== null && maxCount !== undefined && resolved.length >= maxCount) break;
}
return resolved;
}
export function applyGenreFetchOptionsToCandidate<T extends { genres?: string[] }>(
candidate: T,
blockedTokens: ReadonlySet<string>,
maxCount: number | null | undefined,
): T {
if (!candidate.genres?.length) return candidate;
const resolved = applyGenreFetchOptions(candidate.genres, blockedTokens, maxCount);
if (resolved.length === candidate.genres.length && resolved.every((genre, index) => genre === candidate.genres?.[index])) {
return candidate;
}
return {
...candidate,
genres: resolved.length ? resolved : undefined,
};
}
@@ -640,7 +640,7 @@ describe('MetadataFetchPipeline', () => {
};
});
prefs.options = {
genres: { mode: 'merge', blocklist: [] },
genres: { mode: 'merge', blocklist: [], maxCount: null },
saveProviderIds: false,
};
@@ -670,7 +670,7 @@ describe('MetadataFetchPipeline', () => {
};
});
prefs.options = {
genres: { mode: 'merge', blocklist: ['audiobook', ' Adult '] },
genres: { mode: 'merge', blocklist: ['audiobook', ' Adult '], maxCount: null },
saveProviderIds: false,
};
@@ -691,6 +691,36 @@ describe('MetadataFetchPipeline', () => {
expect(sources.genres).toBe(MetadataProviderKey.GOOGLE);
});
it('limits merged genres after exclusions and case-insensitive de-duplication', async () => {
const prefs = createPreferences((fields) => {
fields.genres = {
enabled: true,
providers: [MetadataProviderKey.GOOGLE, MetadataProviderKey.OPEN_LIBRARY],
mergeStrategy: 'overwriteIfProvided',
};
});
prefs.options = {
genres: { mode: 'merge', blocklist: ['Adult'], maxCount: 3 },
saveProviderIds: false,
};
preferencesService.getGlobal.mockResolvedValue(prefs);
resolver.resolve.mockReturnValue(prefs);
resolver.withForwardCompatibility.mockReturnValue(prefs);
registry.all.mockReturnValue([{ key: MetadataProviderKey.GOOGLE }, { key: MetadataProviderKey.OPEN_LIBRARY }] as never);
fetchService.search.mockReturnValue(
of(
candidate(MetadataProviderKey.GOOGLE, 'g1', { genres: ['Fiction', 'Fantasy', 'fantasy', 'Adult'] }),
candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol1', { genres: ['fiction', 'Mystery', 'Classic'] }),
),
);
const { resolved, sources } = await pipeline.runWithSources({ title: 'Query' }, {});
expect(resolved.genres).toEqual(['Fiction', 'Fantasy', 'Mystery']);
expect(sources.genres).toBe(MetadataProviderKey.GOOGLE);
});
it('filters blocklisted genres and falls back in first-provider mode when a provider has no remaining genres', async () => {
const prefs = createPreferences((fields) => {
fields.genres = {
@@ -700,7 +730,7 @@ describe('MetadataFetchPipeline', () => {
};
});
prefs.options = {
genres: { mode: 'firstProvider', blocklist: ['Audiobook'] },
genres: { mode: 'firstProvider', blocklist: ['Audiobook'], maxCount: null },
saveProviderIds: false,
};
@@ -721,6 +751,36 @@ describe('MetadataFetchPipeline', () => {
expect(sources.genres).toBe(MetadataProviderKey.OPEN_LIBRARY);
});
it('limits genres from the selected provider in first-provider mode', async () => {
const prefs = createPreferences((fields) => {
fields.genres = {
enabled: true,
providers: [MetadataProviderKey.GOOGLE, MetadataProviderKey.OPEN_LIBRARY],
mergeStrategy: 'overwriteIfProvided',
};
});
prefs.options = {
genres: { mode: 'firstProvider', blocklist: [], maxCount: 2 },
saveProviderIds: false,
};
preferencesService.getGlobal.mockResolvedValue(prefs);
resolver.resolve.mockReturnValue(prefs);
resolver.withForwardCompatibility.mockReturnValue(prefs);
registry.all.mockReturnValue([{ key: MetadataProviderKey.GOOGLE }, { key: MetadataProviderKey.OPEN_LIBRARY }] as never);
fetchService.search.mockReturnValue(
of(
candidate(MetadataProviderKey.GOOGLE, 'g1', { genres: ['Fantasy', 'Adventure', 'Epic'] }),
candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol1', { genres: ['Classic'] }),
),
);
const { resolved, sources } = await pipeline.runWithSources({ title: 'Query' }, {});
expect(resolved.genres).toEqual(['Fantasy', 'Adventure']);
expect(sources.genres).toBe(MetadataProviderKey.GOOGLE);
});
it('returns provider ids for matched providers when saveProviderIds is enabled', async () => {
const prefs = createPreferences((fields) => {
fields.title = {
@@ -730,7 +790,7 @@ describe('MetadataFetchPipeline', () => {
};
});
prefs.options = {
genres: { mode: 'firstProvider', blocklist: [] },
genres: { mode: 'firstProvider', blocklist: [], maxCount: null },
saveProviderIds: true,
};
@@ -755,7 +815,7 @@ describe('MetadataFetchPipeline', () => {
};
});
prefs.options = {
genres: { mode: 'firstProvider', blocklist: [] },
genres: { mode: 'firstProvider', blocklist: [], maxCount: null },
saveProviderIds: true,
};
@@ -786,7 +846,7 @@ describe('MetadataFetchPipeline', () => {
};
});
prefs.options = {
genres: { mode: 'firstProvider', blocklist: [] },
genres: { mode: 'firstProvider', blocklist: [], maxCount: null },
saveProviderIds: true,
};
@@ -813,7 +873,7 @@ describe('MetadataFetchPipeline', () => {
};
});
prefs.options = {
genres: { mode: 'firstProvider', blocklist: [] },
genres: { mode: 'firstProvider', blocklist: [], maxCount: null },
saveProviderIds: false,
};
@@ -837,7 +897,7 @@ describe('MetadataFetchPipeline', () => {
};
});
prefs.options = {
genres: { mode: 'firstProvider', blocklist: [] },
genres: { mode: 'firstProvider', blocklist: [], maxCount: null },
saveProviderIds: false,
};
@@ -18,7 +18,7 @@ import { MetadataPreferenceResolver } from '../metadata-preferences/metadata-pre
import { ProviderConfigService } from '../metadata-preferences/provider-config.service';
import { MetadataPreferencesService } from '../metadata-preferences/metadata-preferences.service';
import { SeriesExpectedCountService } from '../../common/services/series-expected-count.service';
import { createGenreBlocklistTokenSet, filterGenresAgainstBlocklist } from '../../common/utils/genre-blocklist.utils';
import { applyGenreFetchOptions, createGenreBlocklistTokenSet } from '../../common/utils/genre-fetch-options.utils';
import { normalizeMetadataText, normalizeMetadataTextKey } from '../../common/utils/metadata-text-normalize.utils';
import { MetadataFetchService } from './metadata-fetch.service';
import { ProviderRegistry } from './provider-registry';
@@ -260,7 +260,8 @@ export class MetadataFetchPipeline {
): { resolved: ResolvedMetadataFields; sources: Record<string, string>; providerIds: ResolvedProviderIds } {
const result: ResolvedMetadataFields = {};
const sources: Record<string, string> = {};
const blockedGenreTokens = createGenreBlocklistTokenSet(preferences.options?.genres.blocklist);
const genreOptions = preferences.options?.genres;
const blockedGenreTokens = createGenreBlocklistTokenSet(genreOptions?.blocklist);
for (const field of Object.keys(preferences.fields) as MetadataField[]) {
const fieldPreference = preferences.fields[field];
@@ -268,7 +269,12 @@ export class MetadataFetchPipeline {
const mergeStrategy = options?.preserveExisting ? 'fillMissing' : fieldPreference.mergeStrategy;
if (field === 'genres' && preferences.options?.genres.mode === 'merge') {
const { genres, sourceProvider } = this.mergeGenres(fieldPreference.providers as MetadataProviderKey[], byProvider, blockedGenreTokens);
const { genres, sourceProvider } = this.mergeGenres(
fieldPreference.providers as MetadataProviderKey[],
byProvider,
blockedGenreTokens,
genreOptions?.maxCount,
);
if (!genres.length) continue;
const existingValue = existing[field];
@@ -315,11 +321,11 @@ export class MetadataFetchPipeline {
break;
}
if (field === 'genres' && blockedGenreTokens.size > 0) {
if (field === 'genres') {
if (!Array.isArray(value)) continue;
const filteredGenres = filterGenresAgainstBlocklist(value, blockedGenreTokens);
if (!filteredGenres.length) continue;
value = filteredGenres;
const genres = applyGenreFetchOptions(value, blockedGenreTokens, genreOptions?.maxCount);
if (!genres.length) continue;
value = genres;
}
const existingValue = existing[field];
@@ -478,25 +484,26 @@ export class MetadataFetchPipeline {
return normalized;
}
private mergeGenres(providerKeys: MetadataProviderKey[], byProvider: Map<string, MetadataCandidate>, blockedGenreTokens: ReadonlySet<string>) {
private mergeGenres(
providerKeys: MetadataProviderKey[],
byProvider: Map<string, MetadataCandidate>,
blockedGenreTokens: ReadonlySet<string>,
maxCount: number | null | undefined,
) {
const merged: string[] = [];
const seen = new Set<string>();
let sourceProvider: MetadataProviderKey | undefined;
for (const providerKey of providerKeys) {
const candidate = byProvider.get(providerKey);
if (!candidate?.genres?.length) continue;
for (const genre of filterGenresAgainstBlocklist(candidate.genres, blockedGenreTokens)) {
const token = genre.toLowerCase();
if (seen.has(token)) continue;
if (!sourceProvider) sourceProvider = providerKey;
seen.add(token);
merged.push(genre);
}
const providerGenres = applyGenreFetchOptions(candidate.genres, blockedGenreTokens, null);
if (!providerGenres.length) continue;
if (!sourceProvider) sourceProvider = providerKey;
merged.push(...providerGenres);
}
return { genres: merged, sourceProvider };
return { genres: applyGenreFetchOptions(merged, blockedGenreTokens, maxCount), sourceProvider };
}
private isMissing(value: unknown): boolean {
@@ -165,19 +165,27 @@ describe('MetadataFetchController', () => {
expect(service.search).toHaveBeenCalledWith(expect.objectContaining({ title: 'Dune' }), [MetadataProviderKey.KOBO]);
});
it('filters blocklisted genres from streamed metadata candidates', async () => {
it('applies genre exclusions, de-duplication, and limits to streamed metadata candidates', async () => {
const resolver = new MetadataPreferenceResolver();
const preferences = resolver.getDefaultPreferences();
preferences.options!.genres.blocklist = ['Audiobook'];
preferences.options!.genres.maxCount = 2;
metadataPreferences.getGlobal.mockResolvedValue(preferences);
service.search.mockReturnValue(
of({ provider: MetadataProviderKey.GOOGLE, providerId: 'vol-1', title: 'First', genres: ['Science Fiction', 'audiobook'] }),
of({
provider: MetadataProviderKey.GOOGLE,
providerId: 'vol-1',
title: 'First',
genres: ['Science Fiction', 'science fiction', 'audiobook', 'Space Opera', 'Fantasy'],
}),
);
const stream = await controller.stream({ title: 'Dune' }, user);
const events = await firstValueFrom(stream.pipe(toArray()));
expect(events).toEqual([{ data: { provider: MetadataProviderKey.GOOGLE, providerId: 'vol-1', title: 'First', genres: ['Science Fiction'] } }]);
expect(events).toEqual([
{ data: { provider: MetadataProviderKey.GOOGLE, providerId: 'vol-1', title: 'First', genres: ['Science Fiction', 'Space Opera'] } },
]);
});
it('skips stored provider lookup when bookId is not provided', async () => {
@@ -325,7 +333,7 @@ describe('MetadataFetchController', () => {
);
});
it('delegates lookup requests to the metadata fetch service and filters blocklisted genres', async () => {
it('delegates lookup requests and applies genre fetch options', async () => {
providerConfig.getConfig.mockResolvedValue(
makeProviderConfig({
amazon: { enabled: true, domain: 'amazon.com', cookie: '' },
@@ -334,12 +342,13 @@ describe('MetadataFetchController', () => {
const resolver = new MetadataPreferenceResolver();
const preferences = resolver.getDefaultPreferences();
preferences.options!.genres.blocklist = ['Adult'];
preferences.options!.genres.maxCount = 1;
metadataPreferences.getGlobal.mockResolvedValue(preferences);
service.lookupById.mockResolvedValue({
provider: MetadataProviderKey.AMAZON,
providerId: 'B123',
title: 'Amazon Title',
genres: ['Adult', 'Mystery'],
genres: ['Adult', 'Mystery', 'Thriller'],
});
const dto: LookupMetadataDto = { provider: MetadataProviderKey.AMAZON, id: 'B123' };
@@ -13,7 +13,7 @@ import { ProviderRegistry } from './provider-registry';
import { MetadataSearchParams } from './providers/metadata-search-params';
import { ProviderConfigService } from '../metadata-preferences/provider-config.service';
import { MetadataPreferencesService } from '../metadata-preferences/metadata-preferences.service';
import { createGenreBlocklistTokenSet, filterCandidateGenresAgainstBlocklist } from '../../common/utils/genre-blocklist.utils';
import { applyGenreFetchOptionsToCandidate, createGenreBlocklistTokenSet } from '../../common/utils/genre-fetch-options.utils';
import { ProviderThrottleTracker } from './provider-throttle.tracker';
import { ListMetadataProvidersDto } from './dto/list-metadata-providers.dto';
@@ -88,11 +88,14 @@ export class MetadataFetchController {
isAudiobook,
};
const blockedGenreTokens = createGenreBlocklistTokenSet(preferences.options?.genres.blocklist);
const genreOptions = preferences.options?.genres;
const blockedGenreTokens = createGenreBlocklistTokenSet(genreOptions?.blocklist);
return this.metadataFetchService
.search(params, providerKeys)
.pipe(map((candidate: MetadataCandidate) => ({ data: filterCandidateGenresAgainstBlocklist(candidate, blockedGenreTokens) })));
.pipe(
map((candidate: MetadataCandidate) => ({ data: applyGenreFetchOptionsToCandidate(candidate, blockedGenreTokens, genreOptions?.maxCount) })),
);
}
@Get('lookup')
@@ -105,8 +108,9 @@ export class MetadataFetchController {
this.metadataPreferences.getGlobal(),
]);
if (!candidate) return null;
const blockedGenreTokens = createGenreBlocklistTokenSet(preferences.options?.genres.blocklist);
return filterCandidateGenresAgainstBlocklist(candidate, blockedGenreTokens);
const genreOptions = preferences.options?.genres;
const blockedGenreTokens = createGenreBlocklistTokenSet(genreOptions?.blocklist);
return applyGenreFetchOptionsToCandidate(candidate, blockedGenreTokens, genreOptions?.maxCount);
}
private async resolveEnabledProviderKeys(requestedProviders?: MetadataProviderKey[]): Promise<MetadataProviderKey[]> {
@@ -105,6 +105,7 @@ describe('UpdateGlobalPreferencesDto', () => {
genres: {
mode: 'merge',
blocklist: ['Audiobook', 'Adult'],
maxCount: 3,
},
saveProviderIds: true,
},
@@ -133,6 +134,52 @@ describe('UpdateGlobalPreferencesDto', () => {
expect(errors).toHaveLength(0);
});
it('accepts unlimited genre results when the maximum is null or omitted', async () => {
const baseInput = {
fields: {
title: {
enabled: true,
providers: [MetadataProviderKey.GOOGLE],
mergeStrategy: 'fillMissing',
},
},
options: {
genres: {
mode: 'merge',
maxCount: null,
},
saveProviderIds: true,
},
};
await expect(validateInput(baseInput)).resolves.toMatchObject({ errors: [] });
delete (baseInput.options.genres as { maxCount?: number | null }).maxCount;
const { dto, errors } = await validateInput(baseInput);
expect(errors).toHaveLength(0);
expect(dto.options?.genres.maxCount).toBeNull();
});
it('rejects genre limits outside the supported integer range', async () => {
const inputFor = (maxCount: number) => ({
fields: {
title: {
enabled: true,
providers: [MetadataProviderKey.GOOGLE],
mergeStrategy: 'fillMissing',
},
},
options: {
genres: { mode: 'merge', maxCount },
saveProviderIds: true,
},
});
expect((await validateInput(inputFor(0))).errors).not.toHaveLength(0);
expect((await validateInput(inputFor(2.5))).errors).not.toHaveLength(0);
expect((await validateInput(inputFor(51))).errors).not.toHaveLength(0);
});
it('rejects invalid advanced options', async () => {
const { errors } = await validateInput({
fields: {
@@ -146,6 +193,7 @@ describe('UpdateGlobalPreferencesDto', () => {
genres: {
mode: 'invalid',
blocklist: ['ok', 42],
maxCount: 0,
},
saveProviderIds: 'yes',
},
@@ -3,8 +3,11 @@ import {
IsIn,
IsBoolean,
IsArray,
IsInt,
IsString,
Max,
MaxLength,
Min,
IsOptional,
Validate,
ValidateNested,
@@ -13,7 +16,7 @@ import {
validateSync,
} from 'class-validator';
import { plainToInstance, Type } from 'class-transformer';
import { ALL_METADATA_FIELDS, GENRE_MERGE_MODES, MERGE_STRATEGIES, MetadataProviderKey } from '@bookorbit/types';
import { ALL_METADATA_FIELDS, GENRE_MERGE_MODES, MAX_METADATA_GENRE_COUNT, MERGE_STRATEGIES, MetadataProviderKey } from '@bookorbit/types';
import type { GenreMergeMode, MergeStrategy, MetadataField } from '@bookorbit/types';
const PROVIDER_KEYS = Object.values(MetadataProviderKey);
@@ -40,6 +43,12 @@ export class GenreOptionsDto {
@IsString({ each: true })
@MaxLength(120, { each: true })
blocklist!: string[];
@IsOptional()
@IsInt()
@Min(1)
@Max(MAX_METADATA_GENRE_COUNT)
maxCount: number | null = null;
}
export class MetadataFetchOptionsDto {
@@ -75,7 +75,7 @@ describe('MetadataPreferenceResolver', () => {
expect(defaults.fields.title.mergeStrategy).toBe('overwriteIfProvided');
expect(defaults.fields.description.mergeStrategy).toBe('overwriteIfProvided');
expect(defaults.options).toEqual({
genres: { mode: 'merge', blocklist: [] },
genres: { mode: 'merge', blocklist: [], maxCount: null },
saveProviderIds: true,
});
});
@@ -132,7 +132,7 @@ describe('MetadataPreferenceResolver', () => {
const malformed = {
fields: defaults.fields,
options: {
genres: { mode: 'invalid', blocklist: [42] },
genres: { mode: 'invalid', blocklist: [42], maxCount: 0 },
saveProviderIds: 'yes',
},
} as unknown as MetadataFetchPreferences;
@@ -150,6 +150,7 @@ describe('MetadataPreferenceResolver', () => {
genres: {
mode: 'merge',
blocklist: [' Audiobook ', 'adult', 'AUDIOBOOK', '', 'Graphic Novel'],
maxCount: 3,
},
saveProviderIds: true,
},
@@ -158,6 +159,28 @@ describe('MetadataPreferenceResolver', () => {
const resolved = resolver.resolve(preferences, null);
expect(resolved.options?.genres.blocklist).toEqual(['Audiobook', 'adult', 'Graphic Novel']);
expect(resolved.options?.genres.maxCount).toBe(3);
});
it('normalizes missing and invalid genre limits to unlimited', () => {
const defaults = resolver.getDefaultPreferences();
const missingLimit = resolver.resolve(
{
fields: defaults.fields,
options: { genres: { mode: 'merge', blocklist: [] }, saveProviderIds: true },
} as unknown as MetadataFetchPreferences,
null,
);
const invalidLimit = resolver.resolve(
{
fields: defaults.fields,
options: { genres: { mode: 'merge', blocklist: [], maxCount: 51 }, saveProviderIds: true },
},
null,
);
expect(missingLimit.options?.genres.maxCount).toBeNull();
expect(invalidLimit.options?.genres.maxCount).toBeNull();
});
it('preserves explicit provider selections when applying forward compatibility', () => {
@@ -5,6 +5,7 @@ import {
FieldPreference,
FieldPreferenceOverrides,
GENRE_MERGE_MODES,
MAX_METADATA_GENRE_COUNT,
MetadataFetchPreferences,
MetadataFetchOptions,
MetadataField,
@@ -13,7 +14,7 @@ import {
MergeStrategy,
} from '@bookorbit/types';
import { normalizeGenreBlocklist } from '../../common/utils/genre-blocklist.utils';
import { normalizeGenreBlocklist } from '../../common/utils/genre-fetch-options.utils';
const DEFAULT_PROVIDER_ORDER: MetadataProviderKey[] = [
MetadataProviderKey.GOODREADS,
@@ -72,6 +73,7 @@ export class MetadataPreferenceResolver {
genres: {
mode: 'merge',
blocklist: [],
maxCount: null,
},
saveProviderIds: true,
};
@@ -143,11 +145,17 @@ export class MetadataPreferenceResolver {
? (genresCandidate.mode as MetadataFetchOptions['genres']['mode'])
: fallback.genres.mode;
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;
return {
genres: { mode, blocklist },
genres: { mode, blocklist, maxCount },
saveProviderIds,
};
}
private normalizeGenreMaxCount(value: unknown, fallback: number | null): number | null {
if (value === null) return null;
return Number.isInteger(value) && (value as number) >= 1 && (value as number) <= MAX_METADATA_GENRE_COUNT ? (value as number) : fallback;
}
}