fix(search): follow-ups to per-user book languages (#1267)

Review follow-ups to #1255, all in the code that PR touched.

Drop the dead user_id from the Prowlarr retry path.
ProwlarrSource.search
never reads plan.languages, and _refresh_release builds a synthetic book
with no titles_by_language, so the title variants came out identical
with
and without it. It also should not language-filter: it re-finds one
exact
release by its guid.

Pin the tab move in tests. BOOK_LANGUAGE moved from the General tab to
Search Mode with no migration, which only works because both tabs
persist
into the same settings.json. Nothing asserted that, so splitting the
files
later would silently reset every install to ["en"]. Covers the stored
value, a fresh install, and ENV precedence.

Stop the UI inventing a default language. An empty BOOK_LANGUAGE is a
deliberate "no default filter" that the backend preserves, but the two
frontend call sites replaced it with the first supported language, so
the
filter said English where the server filtered nothing.
resolveDefaultLanguageCodes
now falls back only when the value is absent.

Keep the normalized value for every validated search key.
validate_user_settings
gated the write-back on a hand-maintained subset of the keys the search
validator recognises, so METADATA_PROVIDER_COMBINED,
SHOW_COMBINED_SELECTOR
and FORCE_COMBINED_SEARCH were validated and then stored raw -- a padded
provider name was accepted and persisted with its padding. Reuse the
validator's own key set instead.

Skip blank language entries rather than rejecting them, so "" and "en,"
mean the same as [] and ["en"] instead of erroring on an unnamed
language.

Extract resolveListOverride for the list-override detection that was
copy-pasted between the two user-settings sections, and mention
languages
in the Search Preferences section description.
This commit is contained in:
CaliBrain
2026-08-24 18:11:29 -04:00
committed by GitHub
parent 463ef49ac3
commit ff06a1a581
13 changed files with 207 additions and 51 deletions
+2 -4
View File
@@ -87,6 +87,7 @@ import { bookSupportsTargets } from './utils/bookTargetLoader';
import { buildSearchQuery } from './utils/buildSearchQuery';
import { wasDownloadQueuedAfterResponseError } from './utils/downloadRecovery';
import { getDynamicOptionGroup } from './utils/dynamicFieldOptions';
import { resolveDefaultLanguageCodes } from './utils/languageFilters';
import { getConfiguredMetadataProviderForContentType } from './utils/metadataProviders';
import { getEffectiveMetadataSort } from './utils/metadataSort';
import { isRecord } from './utils/objectHelpers';
@@ -1927,10 +1928,7 @@ function App() {
);
const supportedFormats = config?.supported_formats || DEFAULT_SUPPORTED_FORMATS;
const defaultLanguageCodes = useMemo(
() =>
config?.default_language && config.default_language.length > 0
? config.default_language
: [bookLanguages[0]?.code || 'en'],
() => resolveDefaultLanguageCodes(config?.default_language, bookLanguages),
[config?.default_language, bookLanguages],
);
@@ -3,6 +3,7 @@ import type { Dispatch, SetStateAction } from 'react';
import { useMountEffect } from '@/hooks/useMountEffect';
import type { AppConfig, AdvancedFilterState, ContentType, SearchMode, SortOption } from '@/types';
import { buildSearchQuery } from '@/utils/buildSearchQuery';
import { resolveDefaultLanguageCodes } from '@/utils/languageFilters';
import { getEffectiveMetadataSort } from '@/utils/metadataSort';
import type { ParsedUrlSearch } from '@/utils/parseUrlSearchParams';
@@ -73,10 +74,10 @@ export const UrlSearchBootstrapMount = ({
}
const bookLanguages = config.book_languages || [];
const defaultLanguageCodes =
config.default_language && config.default_language.length > 0
? config.default_language
: [bookLanguages[0]?.code || 'en'];
const defaultLanguageCodes = resolveDefaultLanguageCodes(
config.default_language,
bookLanguages,
);
if (parsedParams.searchInput) {
setSearchInput(parsedParams.searchInput);
@@ -9,8 +9,8 @@ import { HeadingField, MultiSelectField, SelectField, TextField } from '../field
import { FieldWrapper } from '../shared';
import {
getFieldByKey,
resolveListOverride,
toNormalizedLowercaseTextValue,
toStringListValue,
toTextValue,
} from './fieldHelpers';
import type { PerUserSettings } from './types';
@@ -180,12 +180,6 @@ export const UserOverridesSection = ({
label: 'Email Recipient',
description: 'Email address used for this user in Email output mode.',
};
const browserDownloadGlobalValue = toStringListValue(
globalValues.DOWNLOAD_TO_BROWSER_CONTENT_TYPES,
);
const browserDownloadUserValue = toStringListValue(
userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES,
);
const isOverridden = (key: DeliverySettingKey): boolean => {
if (
@@ -201,10 +195,12 @@ export const UserOverridesSection = ({
return userValue !== globalValue;
};
const isBrowserDownloadOverridden =
Object.prototype.hasOwnProperty.call(userSettings, 'DOWNLOAD_TO_BROWSER_CONTENT_TYPES') &&
userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES !== null &&
JSON.stringify(browserDownloadUserValue) !== JSON.stringify(browserDownloadGlobalValue);
const { value: browserDownloadContentTypes, isOverridden: isBrowserDownloadOverridden } =
resolveListOverride(
userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES,
globalValues.DOWNLOAD_TO_BROWSER_CONTENT_TYPES,
Object.prototype.hasOwnProperty.call(userSettings, 'DOWNLOAD_TO_BROWSER_CONTENT_TYPES'),
);
const resetKeys = (keys: DeliverySettingKey[]) => {
setUserSettings((prev) => {
@@ -233,9 +229,6 @@ export const UserOverridesSection = ({
const outputModeValue = readValue('BOOKS_OUTPUT_MODE', 'folder');
const effectiveOutputMode = normalizeMode(outputModeValue);
const browserDownloadContentTypes = isBrowserDownloadOverridden
? browserDownloadUserValue
: browserDownloadGlobalValue;
const destinationValue = readValue('DESTINATION');
const destinationAudiobookValue = readValue('DESTINATION_AUDIOBOOK');
const libraryValue = readValue('BOOKLORE_LIBRARY_ID');
@@ -8,8 +8,8 @@ import { HeadingField, MultiSelectField, SelectField } from '../fields';
import { FieldWrapper } from '../shared';
import {
getFieldByKey,
resolveListOverride,
toNormalizedLowercaseTextValue,
toStringListValue,
toTextValue,
} from './fieldHelpers';
import type { PerUserSettings } from './types';
@@ -141,15 +141,11 @@ export const UserSearchPreferencesSection = ({
);
const bookLanguageField = getFieldByKey(fields, 'BOOK_LANGUAGE', fallbackBookLanguageField);
const bookLanguageGlobalValue = toStringListValue(globalValues.BOOK_LANGUAGE);
const bookLanguageUserValue = toStringListValue(userSettings.BOOK_LANGUAGE);
const isBookLanguageOverridden =
Object.prototype.hasOwnProperty.call(userSettings, 'BOOK_LANGUAGE') &&
userSettings.BOOK_LANGUAGE !== null &&
JSON.stringify(bookLanguageUserValue) !== JSON.stringify(bookLanguageGlobalValue);
const bookLanguageValue = isBookLanguageOverridden
? bookLanguageUserValue
: bookLanguageGlobalValue;
const { value: bookLanguageValue, isOverridden: isBookLanguageOverridden } = resolveListOverride(
userSettings.BOOK_LANGUAGE,
globalValues.BOOK_LANGUAGE,
Object.prototype.hasOwnProperty.call(userSettings, 'BOOK_LANGUAGE'),
);
const isOverridden = (key: SearchSettingKey): boolean => {
if (
@@ -31,13 +31,33 @@ export const toNormalizedLowercaseTextValue = (value: unknown): string => {
return toTrimmedTextValue(value).toLowerCase();
};
export const toStringListValue = (value: unknown): string[] => {
const toStringListValue = (value: unknown): string[] => {
if (!Array.isArray(value)) {
return [];
}
return value.map((entry) => toTrimmedTextValue(entry)).filter((entry) => entry.length > 0);
};
/**
* Resolve a list-valued per-user override against its global value.
*
* A key absent from userSettings, or set to null, is not an override. A stored list
* that matches the global one is treated as inherited, matching how
* buildUserSettingsPayload clears it on save.
*/
export const resolveListOverride = (
userValue: unknown,
globalValue: unknown,
hasUserKey: boolean,
): { value: string[]; isOverridden: boolean } => {
const globalList = toStringListValue(globalValue);
const userList = toStringListValue(userValue);
const isOverridden =
hasUserKey && userValue !== null && JSON.stringify(userList) !== JSON.stringify(globalList);
return { value: isOverridden ? userList : globalList, isOverridden };
};
export const toComparableValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '';
@@ -7,6 +7,7 @@ import {
buildLanguageNormalizer,
getReleaseSearchLanguageParams,
releaseLanguageMatchesFilter,
resolveDefaultLanguageCodes,
} from '../utils/languageFilters';
const supportedLanguages: Language[] = [
@@ -67,3 +68,36 @@ describe('releaseLanguageMatchesFilter', () => {
expect(visibleLanguages).toHaveLength(48);
});
});
describe('resolveDefaultLanguageCodes', () => {
it('keeps an explicitly empty default as "no default filter"', () => {
// The backend stores [] to mean "do not filter"; substituting the first
// supported language here would make the UI filter where the server does not.
expect(resolveDefaultLanguageCodes([], supportedLanguages)).toEqual([]);
});
it('leaves a configured default untouched', () => {
expect(resolveDefaultLanguageCodes(['de', 'hu'], supportedLanguages)).toEqual(['de', 'hu']);
});
it('falls back to the first supported language only when nothing is configured', () => {
expect(resolveDefaultLanguageCodes(undefined, supportedLanguages)).toEqual(['en']);
expect(resolveDefaultLanguageCodes(null, [])).toEqual(['en']);
});
it('sends no language filter when an empty default is the whole selection', () => {
const defaults = resolveDefaultLanguageCodes([], supportedLanguages);
expect(
getReleaseSearchLanguageParams([LANGUAGE_OPTION_DEFAULT], supportedLanguages, defaults),
).toBe(undefined);
});
it('does not smuggle the first language into a Default+German selection', () => {
const defaults = resolveDefaultLanguageCodes([], supportedLanguages);
expect(
getReleaseSearchLanguageParams([LANGUAGE_OPTION_DEFAULT, 'de'], supportedLanguages, defaults),
).toEqual(['de']);
});
});
+18
View File
@@ -27,6 +27,24 @@ export const normalizeLanguageSelection = (selected: string[]): string[] => {
return unique.length ? unique : [LANGUAGE_OPTION_DEFAULT];
};
/**
* Resolve the language codes the "Default" filter option stands for.
*
* An explicitly empty list is a deliberate "no default filter" and is returned as-is;
* only a missing value falls back to the first supported language. Substituting a
* language for the empty list would make the UI filter by a language the backend
* does not apply.
*/
export const resolveDefaultLanguageCodes = (
configuredDefault: string[] | null | undefined,
supportedLanguages: Language[],
): string[] => {
if (Array.isArray(configuredDefault)) {
return configuredDefault;
}
return [supportedLanguages[0]?.code || 'en'];
};
export const getLanguageFilterValues = (
selection: string[],
supportedLanguages: Language[],