mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user