diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 5db66394..7838d0cd 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -298,15 +298,6 @@ Audiobook formats to include in search results. ZIP/RAR archives are extracted a - **Type:** string (comma-separated) - **Default:** `m4b,mp3,m4a,mp4,flac,ogg,wma,aac,wav,opus,zip,rar` -#### `BOOK_LANGUAGE` - -**Default Book Languages** - -Default language filter for searches. - -- **Type:** string (comma-separated) -- **Default:** `en` - ## Search Mode @@ -314,6 +305,7 @@ Default language filter for searches. | Variable | Description | Type | Default | |----------|-------------|------|---------| | `SEARCH_MODE` | How you want to search for and download books. | string (choice) | `universal` | +| `BOOK_LANGUAGE` | Default language filter for searches. Users can override this for their own account. | string (comma-separated) | `en` | | `AA_DEFAULT_SORT` | Default sort order for search results. | string (choice) | `relevance` | | `SHOW_RELEASE_SOURCE_LINKS` | Show clickable release-source links in release and details modals. Metadata provider links stay enabled. | boolean | `true` | | `SHOW_COMBINED_SELECTOR` | Show the option to search for and download both a book and audiobook together. | boolean | `true` | @@ -337,6 +329,15 @@ How you want to search for and download books. - **Default:** `universal` - **Options:** `direct` (Direct), `universal` (Universal) +#### `BOOK_LANGUAGE` + +**Default Book Languages** + +Default language filter for searches. Users can override this for their own account. + +- **Type:** string (comma-separated) +- **Default:** `en` + #### `AA_DEFAULT_SORT` **Default Sort Order** @@ -749,6 +750,7 @@ Automatically open the downloads sidebar when a new download is queued. Automatically download completed files to your browser for the selected content types. - **Type:** string (comma-separated) + - **Default:** _empty list_ #### `MAX_CONCURRENT_DOWNLOADS` diff --git a/docs/users-and-requests.md b/docs/users-and-requests.md index 79c81e40..c6a98170 100644 --- a/docs/users-and-requests.md +++ b/docs/users-and-requests.md @@ -30,7 +30,7 @@ Requires mounting your Calibre-Web `app.db` to `/auth/app.db`. Admins can configure per-user settings by editing a user in the user management panel. Non-admin users can also edit their own settings through **My Account** (accessible from the user menu). Admins control which sections are visible in My Account via the **Visible Self-Settings Sections** option. -There are three categories of per-user settings: +There are four categories of per-user settings: ### Delivery Preferences @@ -42,6 +42,15 @@ Override where a user's downloads are sent. Options depend on the global output - **BookLore library/path** — Per-user BookLore target (when using BookLore output mode) - **Email recipient** — Per-user email address (when using Email output mode) +### Search Preferences + +Override how a user searches, on top of the global search defaults: + +- **Search mode** — Direct or Universal for this user +- **Default book languages** — The languages a user's searches fall back to when they don't pick one themselves. Useful for a shared instance where readers want different languages. +- **Metadata providers** — Book, audiobook, and combined-mode provider for this user +- **Default release sources** — The release tab opened first for books and audiobooks + ### Notifications Users can configure personal notification routes, separate from the global notification settings. Each route targets a URL (e.g. an Apprise-compatible endpoint) and can be scoped to specific event types or all events. diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 1d39b495..a119d2c2 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -430,13 +430,6 @@ def general_settings() -> list[SettingsField]: options=_AUDIOBOOK_FORMAT_OPTIONS, default=[*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS], ), - MultiSelectField( - key="BOOK_LANGUAGE", - label="Default Book Languages", - description="Default language filter for searches.", - options=_LANGUAGE_OPTIONS, - default=["en"], - ), ] @@ -474,6 +467,17 @@ def search_mode_settings() -> list[SettingsField]: default="universal", user_overridable=True, ), + MultiSelectField( + key="BOOK_LANGUAGE", + label="Default Book Languages", + description=( + "Default language filter for searches. Users can override this for their " + "own account." + ), + options=_LANGUAGE_OPTIONS, + default=["en"], + user_overridable=True, + ), SelectField( key="AA_DEFAULT_SORT", label="Default Sort Order", diff --git a/shelfmark/config/users_settings.py b/shelfmark/config/users_settings.py index f5b84604..c54f36c3 100644 --- a/shelfmark/config/users_settings.py +++ b/shelfmark/config/users_settings.py @@ -7,6 +7,7 @@ that talks to /api/admin/users endpoints. from typing import Any +from shelfmark.core.languages import normalize_language from shelfmark.core.request_policy import ( get_source_content_type_capabilities, parse_policy_mode, @@ -79,6 +80,7 @@ _SEARCH_PREFERENCE_PROVIDER_KEYS = { } _SEARCH_PREFERENCE_VALIDATABLE_KEYS = { "SEARCH_MODE", + "BOOK_LANGUAGE", "DEFAULT_RELEASE_SOURCE", "DEFAULT_RELEASE_SOURCE_AUDIOBOOK", "SHOW_COMBINED_SELECTOR", @@ -178,6 +180,28 @@ def _get_request_policy_rule_columns() -> list[dict[str, object]]: ] +def _validate_book_languages(value: Any) -> tuple[Any, str | None]: + """Validate a per-user default language list against the known languages. + + Accepts the list the settings UI sends as well as the comma-separated string the + env var uses. An empty list is a deliberate override meaning "no default language + filter", so it is kept as-is; ``None`` clears the override further up the chain. + """ + entries = value.split(",") if isinstance(value, str) else value + if not isinstance(entries, (list, tuple)): + return value, "BOOK_LANGUAGE must be a list of language codes" + + normalized: list[str] = [] + for entry in entries: + code = normalize_language(entry) + if code is None: + return value, f"BOOK_LANGUAGE contains an unsupported language: {entry}" + if code not in normalized: + normalized.append(code) + + return normalized, None + + def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | None]: """Validate and normalize a search preference value for user overrides.""" if key not in _SEARCH_PREFERENCE_VALIDATABLE_KEYS: @@ -186,6 +210,9 @@ def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | N if value is None: return None, None + if key == "BOOK_LANGUAGE": + return _validate_book_languages(value) + normalized_value = str(value).strip() if key == "SEARCH_MODE": diff --git a/shelfmark/core/admin_settings_routes.py b/shelfmark/core/admin_settings_routes.py index c754120f..a5c2f0a7 100644 --- a/shelfmark/core/admin_settings_routes.py +++ b/shelfmark/core/admin_settings_routes.py @@ -93,6 +93,7 @@ def validate_user_settings( continue if key in { "SEARCH_MODE", + "BOOK_LANGUAGE", "METADATA_PROVIDER", "METADATA_PROVIDER_AUDIOBOOK", "DEFAULT_RELEASE_SOURCE", diff --git a/shelfmark/core/search_plan.py b/shelfmark/core/search_plan.py index 400d220d..0249ad69 100644 --- a/shelfmark/core/search_plan.py +++ b/shelfmark/core/search_plan.py @@ -52,9 +52,9 @@ class ReleaseSearchPlan: return self.title_variants[0].query if self.title_variants else "" -def _normalize_languages(languages: list[str] | None) -> list[str] | None: +def _normalize_languages(languages: list[str] | None, user_id: int | None) -> list[str] | None: if not languages: - default = getattr(config, "BOOK_LANGUAGE", None) + default = config.get("BOOK_LANGUAGE", None, user_id=user_id) if isinstance(default, str): default_values: list[object] = [default] elif isinstance(default, Iterable) and not isinstance(default, (bytes, bytearray, dict)): @@ -102,9 +102,15 @@ def build_release_search_plan( manual_query: str | None = None, indexers: list[str] | None = None, source_filters: SearchFilters | None = None, + user_id: int | None = None, ) -> ReleaseSearchPlan: - """Build normalized search variants shared across release sources.""" - resolved_languages = _normalize_languages(languages) + """Build normalized search variants shared across release sources. + + ``user_id`` picks up that user's default languages when the caller does not + filter explicitly, so a search started without a language filter uses the + reader's own default rather than the instance-wide one. + """ + resolved_languages = _normalize_languages(languages, user_id) resolved_manual_query = None if manual_query: diff --git a/shelfmark/main.py b/shelfmark/main.py index 04f1f736..68903b34 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -1150,7 +1150,7 @@ def api_config() -> Response | tuple[Response, int]: "build_version": BUILD_VERSION, "release_version": RELEASE_VERSION, "book_languages": _SUPPORTED_BOOK_LANGUAGE, - "default_language": app_config.BOOK_LANGUAGE, + "default_language": app_config.get("BOOK_LANGUAGE", ["en"], user_id=db_user_id), "supported_formats": app_config.SUPPORTED_FORMATS, "supported_audiobook_formats": app_config.SUPPORTED_AUDIOBOOK_FORMATS, "search_mode": search_mode, @@ -2840,6 +2840,7 @@ def api_releases() -> Response | tuple[Response, int]: manual_query=query_text if source_query_filters is not None else manual_query, indexers=indexers, source_filters=source_query_filters, + user_id=db_user_id, ) if plan.source_filters is not None: @@ -2892,6 +2893,8 @@ def api_releases() -> Response | tuple[Response, int]: if languages_param else None ) + # Without an explicit filter the plan falls back to this user's default languages. + db_user_id = get_session_db_user_id(session) # Content type for audiobook vs ebook search content_type = request.args.get("content_type", "ebook").strip() diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index 6c6ec955..9ea51914 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -300,6 +300,7 @@ class ProwlarrHandler(ExternalClientHandler): plan = build_release_search_plan( book, indexers=[indexer] if indexer is not None else None, + user_id=task.user_id, ) source = ProwlarrSource() diff --git a/src/frontend/src/components/settings/users/UserOverridesSection.tsx b/src/frontend/src/components/settings/users/UserOverridesSection.tsx index 6059ef64..20d497ec 100644 --- a/src/frontend/src/components/settings/users/UserOverridesSection.tsx +++ b/src/frontend/src/components/settings/users/UserOverridesSection.tsx @@ -7,7 +7,12 @@ import type { } from '../../../types/settings'; import { HeadingField, MultiSelectField, SelectField, TextField } from '../fields'; import { FieldWrapper } from '../shared'; -import { getFieldByKey, toNormalizedLowercaseTextValue, toTextValue } from './fieldHelpers'; +import { + getFieldByKey, + toNormalizedLowercaseTextValue, + toStringListValue, + toTextValue, +} from './fieldHelpers'; import type { PerUserSettings } from './types'; interface UserOverridesSectionProps { @@ -175,16 +180,12 @@ export const UserOverridesSection = ({ label: 'Email Recipient', description: 'Email address used for this user in Email output mode.', }; - const browserDownloadGlobalValue = Array.isArray(globalValues.DOWNLOAD_TO_BROWSER_CONTENT_TYPES) - ? globalValues.DOWNLOAD_TO_BROWSER_CONTENT_TYPES.map((entry) => String(entry).trim()).filter( - (entry) => entry.length > 0, - ) - : []; - const browserDownloadUserValue = Array.isArray(userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES) - ? userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES.map((entry) => entry.trim()).filter( - (entry) => entry.length > 0, - ) - : []; + const browserDownloadGlobalValue = toStringListValue( + globalValues.DOWNLOAD_TO_BROWSER_CONTENT_TYPES, + ); + const browserDownloadUserValue = toStringListValue( + userSettings.DOWNLOAD_TO_BROWSER_CONTENT_TYPES, + ); const isOverridden = (key: DeliverySettingKey): boolean => { if ( diff --git a/src/frontend/src/components/settings/users/UserSearchPreferencesSection.tsx b/src/frontend/src/components/settings/users/UserSearchPreferencesSection.tsx index d82aefe2..88183fe1 100644 --- a/src/frontend/src/components/settings/users/UserSearchPreferencesSection.tsx +++ b/src/frontend/src/components/settings/users/UserSearchPreferencesSection.tsx @@ -1,8 +1,17 @@ import type { DeliveryPreferencesResponse } from '../../../services/api'; -import type { HeadingFieldConfig, SelectFieldConfig } from '../../../types/settings'; -import { HeadingField, SelectField } from '../fields'; +import type { + HeadingFieldConfig, + MultiSelectFieldConfig, + SelectFieldConfig, +} from '../../../types/settings'; +import { HeadingField, MultiSelectField, SelectField } from '../fields'; import { FieldWrapper } from '../shared'; -import { getFieldByKey, toNormalizedLowercaseTextValue, toTextValue } from './fieldHelpers'; +import { + getFieldByKey, + toNormalizedLowercaseTextValue, + toStringListValue, + toTextValue, +} from './fieldHelpers'; import type { PerUserSettings } from './types'; interface UserSearchPreferencesSectionProps { @@ -14,6 +23,7 @@ interface UserSearchPreferencesSectionProps { type SearchSettingKey = | 'SEARCH_MODE' + | 'BOOK_LANGUAGE' | 'METADATA_PROVIDER' | 'METADATA_PROVIDER_AUDIOBOOK' | 'DEFAULT_RELEASE_SOURCE' @@ -68,6 +78,15 @@ const fallbackDefaultAudiobookReleaseSourceField: SelectFieldConfig = { options: [{ value: '', label: 'Use book release source' }], }; +const fallbackBookLanguageField: MultiSelectFieldConfig = { + type: 'MultiSelectField', + key: 'BOOK_LANGUAGE', + label: 'Default Book Languages', + description: 'Default language filter for searches.', + value: [], + options: [], +}; + const searchHeading: HeadingFieldConfig = { type: 'HeadingField', key: 'search_preferences_heading', @@ -120,6 +139,17 @@ export const UserSearchPreferencesSection = ({ 'DEFAULT_RELEASE_SOURCE_AUDIOBOOK', fallbackDefaultAudiobookReleaseSourceField, ); + 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 isOverridden = (key: SearchSettingKey): boolean => { if ( @@ -172,9 +202,12 @@ export const UserSearchPreferencesSection = ({ const canOverrideDefaultAudiobookReleaseSource = isUserOverridable('DEFAULT_RELEASE_SOURCE_AUDIOBOOK') && preferenceKeySet.has('DEFAULT_RELEASE_SOURCE_AUDIOBOOK'); + const canOverrideBookLanguage = + isUserOverridable('BOOK_LANGUAGE') && preferenceKeySet.has('BOOK_LANGUAGE'); if ( !canOverrideSearchMode && + !canOverrideBookLanguage && !canOverrideMetadataProvider && !canOverrideAudiobookMetadataProvider && !canOverrideDefaultReleaseSource && @@ -208,6 +241,27 @@ export const UserSearchPreferencesSection = ({ )} + {canOverrideBookLanguage && ( + resetKeys(['BOOK_LANGUAGE']), + } + : undefined + } + > + setUserSettings((prev) => ({ ...prev, BOOK_LANGUAGE: value }))} + disabled={Boolean(bookLanguageField.fromEnv)} + /> + + )} + {effectiveSearchMode === 'universal' && canOverrideMetadataProvider && ( { return toTrimmedTextValue(value).toLowerCase(); }; +export const toStringListValue = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return []; + } + return value.map((entry) => toTrimmedTextValue(entry)).filter((entry) => entry.length > 0); +}; + export const toComparableValue = (value: unknown): string => { if (value === null || value === undefined) { return ''; diff --git a/src/frontend/src/components/settings/users/types.ts b/src/frontend/src/components/settings/users/types.ts index 07ab54b5..f767d42a 100644 --- a/src/frontend/src/components/settings/users/types.ts +++ b/src/frontend/src/components/settings/users/types.ts @@ -10,6 +10,7 @@ export interface PerUserSettings { EMAIL_RECIPIENT?: string; DOWNLOAD_TO_BROWSER_CONTENT_TYPES?: string[]; SEARCH_MODE?: string; + BOOK_LANGUAGE?: string[]; METADATA_PROVIDER?: string; METADATA_PROVIDER_AUDIOBOOK?: string; DEFAULT_RELEASE_SOURCE?: string; diff --git a/tests/config/test_general_settings.py b/tests/config/test_general_settings.py new file mode 100644 index 00000000..8dd0d13f --- /dev/null +++ b/tests/config/test_general_settings.py @@ -0,0 +1,11 @@ +"""Tests for general settings definitions.""" + +from shelfmark.config.settings import general_settings + + +def test_supported_formats_stay_admin_only(): + """The format lists describe the library, not a reader, so they are not overridable.""" + fields = {field.key: field for field in general_settings() if hasattr(field, "key")} + + assert fields["SUPPORTED_FORMATS"].user_overridable is False + assert fields["SUPPORTED_AUDIOBOOK_FORMATS"].user_overridable is False diff --git a/tests/config/test_search_mode_settings.py b/tests/config/test_search_mode_settings.py index 45c4862a..17c29f86 100644 --- a/tests/config/test_search_mode_settings.py +++ b/tests/config/test_search_mode_settings.py @@ -11,3 +11,13 @@ def test_search_mode_settings_include_release_source_links_toggle(): assert field.label == "Show Release Source Links" assert field.default is True assert field.user_overridable is False + + +def test_book_language_is_user_overridable(): + fields = {field.key: field for field in search_mode_settings() if hasattr(field, "key")} + + field = fields["BOOK_LANGUAGE"] + + assert field.label == "Default Book Languages" + assert field.default == ["en"] + assert field.user_overridable is True diff --git a/tests/core/test_admin_users_api.py b/tests/core/test_admin_users_api.py index cfc157be..ee6d00a4 100644 --- a/tests/core/test_admin_users_api.py +++ b/tests/core/test_admin_users_api.py @@ -508,6 +508,32 @@ class TestAdminUserUpdateEndpoint: settings = user_db.get_user_settings(user["id"]) assert settings["DESTINATION_AUDIOBOOK"] == "/audiobooks/alice" + def test_update_user_settings_normalizes_book_languages(self, admin_client, user_db): + user = user_db.create_user(username="alice") + + resp = admin_client.put( + f"/api/admin/users/{user['id']}", + json={"settings": {"BOOK_LANGUAGE": ["German", "de", " en "]}}, + ) + assert resp.status_code == 200 + settings = user_db.get_user_settings(user["id"]) + assert settings["BOOK_LANGUAGE"] == ["de", "en"] + + def test_update_user_settings_rejects_unknown_book_language(self, admin_client, user_db): + user = user_db.create_user(username="alice") + + resp = admin_client.put( + f"/api/admin/users/{user['id']}", + json={"settings": {"BOOK_LANGUAGE": ["de", "klingon"]}}, + ) + assert resp.status_code == 400 + assert resp.json["error"] == "Invalid settings payload" + assert any( + "BOOK_LANGUAGE contains an unsupported language: klingon" in msg + for msg in resp.json["details"] + ) + assert user_db.get_user_settings(user["id"]) == {} + def test_update_user_settings_accepts_notification_overrides(self, admin_client, user_db): user = user_db.create_user(username="alice") @@ -1266,6 +1292,7 @@ class TestAdminSearchPreferences: assert data["tab"] == "search_mode" assert data["keys"] == [ "SEARCH_MODE", + "BOOK_LANGUAGE", "SHOW_COMBINED_SELECTOR", "FORCE_COMBINED_SEARCH", "METADATA_PROVIDER", @@ -1295,6 +1322,21 @@ class TestAdminSearchPreferences: assert data["effective"]["DEFAULT_RELEASE_SOURCE_AUDIOBOOK"]["source"] == "user_override" assert data["effective"]["DEFAULT_RELEASE_SOURCE_AUDIOBOOK"]["value"] == "audiobookbay" + def test_reports_book_language_override(self, admin_client, user_db): + user = user_db.create_user(username="alice") + user_db.set_user_settings(user["id"], {"BOOK_LANGUAGE": ["de", "en"]}) + + resp = admin_client.get(f"/api/admin/users/{user['id']}/search-preferences") + assert resp.status_code == 200 + + data = resp.json + assert data["userOverrides"]["BOOK_LANGUAGE"] == ["de", "en"] + assert data["effective"]["BOOK_LANGUAGE"] == { + "value": ["de", "en"], + "source": "user_override", + } + assert data["globalValues"]["BOOK_LANGUAGE"] == ["en"] + def test_returns_404_for_unknown_user(self, admin_client): resp = admin_client.get("/api/admin/users/9999/search-preferences") assert resp.status_code == 404 diff --git a/tests/core/test_config_api.py b/tests/core/test_config_api.py index bd994d42..dc076798 100644 --- a/tests/core/test_config_api.py +++ b/tests/core/test_config_api.py @@ -48,6 +48,7 @@ def test_config_endpoint_uses_user_scope_and_runtime_flags(main_module, client): "DEFAULT_RELEASE_SOURCE": "prowlarr", "DEFAULT_RELEASE_SOURCE_AUDIOBOOK": "audiobookbay", "DOWNLOAD_TO_BROWSER_CONTENT_TYPES": ["book", "audiobook"], + "BOOK_LANGUAGE": ["de", "en"], "AUTO_OPEN_DOWNLOADS_SIDEBAR": False, "HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD": True, "AA_DEFAULT_SORT": "newest", @@ -75,12 +76,14 @@ def test_config_endpoint_uses_user_scope_and_runtime_flags(main_module, client): assert data["default_release_source"] == "prowlarr" assert data["default_release_source_audiobook"] == "audiobookbay" assert data["download_to_browser_content_types"] == ["book", "audiobook"] + assert data["default_language"] == ["de", "en"] assert data["settings_enabled"] is True assert data["metadata_default_sort"] == "relevance" assert ("SHOW_RELEASE_SOURCE_LINKS", None) in calls assert ("SHOW_COMBINED_SELECTOR", 42) in calls assert ("DOWNLOAD_TO_BROWSER_CONTENT_TYPES", 42) in calls + assert ("BOOK_LANGUAGE", 42) in calls def test_config_endpoint_falls_back_to_audiobook_metadata_provider(main_module, client): diff --git a/tests/core/test_config_user_overrides.py b/tests/core/test_config_user_overrides.py index 4b2f8e19..06d8e137 100644 --- a/tests/core/test_config_user_overrides.py +++ b/tests/core/test_config_user_overrides.py @@ -143,3 +143,25 @@ def test_build_user_preferences_payload_reports_effective_sources(monkeypatch): fields_by_key = {field["key"]: field for field in payload["fields"]} assert fields_by_key["DESTINATION"]["fromEnv"] is False assert fields_by_key["BOOKS_OUTPUT_MODE"]["fromEnv"] is True + + +def test_build_user_preferences_payload_carries_the_language_default(): + """BOOK_LANGUAGE rides along with the other search preferences on its tab.""" + import shelfmark.config.settings # noqa: F401 + from shelfmark.core.user_settings_overrides import build_user_preferences_payload + + user_db = SimpleNamespace(get_user_settings=lambda user_id: {"BOOK_LANGUAGE": ["de", "en"]}) + + payload = build_user_preferences_payload(user_db, 7, "search_mode") + + assert payload["tab"] == "search_mode" + assert {"SEARCH_MODE", "BOOK_LANGUAGE"}.issubset(payload["keys"]) + assert payload["userOverrides"] == {"BOOK_LANGUAGE": ["de", "en"]} + assert payload["effective"]["BOOK_LANGUAGE"] == { + "value": ["de", "en"], + "source": "user_override", + } + + fields_by_key = {field["key"]: field for field in payload["fields"]} + assert fields_by_key["BOOK_LANGUAGE"]["type"] == "MultiSelectField" + assert fields_by_key["BOOK_LANGUAGE"]["options"] diff --git a/tests/core/test_manual_query.py b/tests/core/test_manual_query.py index 7b74ad62..7a26b70e 100644 --- a/tests/core/test_manual_query.py +++ b/tests/core/test_manual_query.py @@ -6,7 +6,13 @@ class TestReleaseSearchPlanManualQuery: def test_manual_query_overrides_plan(self, monkeypatch): import shelfmark.core.search_plan as sp - monkeypatch.setattr(sp.config, "BOOK_LANGUAGE", ["en", "hu"], raising=False) + monkeypatch.setattr( + sp.config, + "get", + lambda key, default=None, user_id=None: ( + ["en", "hu"] if key == "BOOK_LANGUAGE" else default + ), + ) book = BookMetadata( provider="hardcover", diff --git a/tests/core/test_releases_api_direct_provider.py b/tests/core/test_releases_api_direct_provider.py index d2b9d1de..c5980461 100644 --- a/tests/core/test_releases_api_direct_provider.py +++ b/tests/core/test_releases_api_direct_provider.py @@ -114,6 +114,41 @@ def test_releases_accepts_direct_download_provider(main_module, client): assert all(call.args == ("direct_download",) for call in mock_get_source.call_args_list) +def test_releases_falls_back_to_the_session_users_default_languages(main_module, client): + """A request without a language filter searches in the reader's own languages.""" + import shelfmark.core.search_plan as search_plan + + planned_languages: list[list[str] | None] = [] + + class _LanguageProbeSource(_FakeDirectSource): + def search(self, book, plan, expand_search=False, content_type="ebook"): + planned_languages.append(plan.languages) + return [] + + def fake_get(key, default=None, user_id=None): + if key == "BOOK_LANGUAGE": + return ["de"] if user_id == 42 else ["en"] + return default + + with client.session_transaction() as session: + session["user_id"] = "reader" + session["db_user_id"] = 42 + + with patch.object(main_module, "get_auth_mode", return_value="none"): + with patch.object(search_plan, "config", SimpleNamespace(get=fake_get)): + with patch("shelfmark.release_sources.get_source", return_value=_LanguageProbeSource()): + resp = client.get( + "/api/releases", + query_string={ + "provider": "direct_download", + "book_id": "md5-abc", + }, + ) + + assert resp.status_code == 200 + assert planned_languages == [["de"]] + + def test_releases_direct_provider_returns_404_when_book_missing(main_module, client): class _MissingDirectSource: def get_record(self, record_id, *, fetch_download_count=True): diff --git a/tests/core/test_search_plan.py b/tests/core/test_search_plan.py index e960ebda..cdff760a 100644 --- a/tests/core/test_search_plan.py +++ b/tests/core/test_search_plan.py @@ -2,12 +2,27 @@ from shelfmark.core.search_plan import build_release_search_plan from shelfmark.metadata_providers import BookMetadata +def _book_language_config_get( + global_languages: list[str], + user_languages: list[str] | None = None, +): + """Stand in for config.get(), answering BOOK_LANGUAGE per user.""" + + def _get(key: str, default: object = None, user_id: int | None = None) -> object: + if key != "BOOK_LANGUAGE": + return default + if user_id is not None and user_languages is not None: + return user_languages + return global_languages + + return _get + + class TestReleaseSearchPlan: def test_uses_default_languages_when_none(self, monkeypatch): - # config.BOOK_LANGUAGE is a Config attribute; patch the instance. import shelfmark.core.search_plan as sp - monkeypatch.setattr(sp.config, "BOOK_LANGUAGE", ["en", "hu"], raising=False) + monkeypatch.setattr(sp.config, "get", _book_language_config_get(["en", "hu"])) book = BookMetadata( provider="hardcover", @@ -46,7 +61,7 @@ class TestReleaseSearchPlan: def test_all_language_disables_grouping(self, monkeypatch): import shelfmark.core.search_plan as sp - monkeypatch.setattr(sp.config, "BOOK_LANGUAGE", ["en"], raising=False) + monkeypatch.setattr(sp.config, "get", _book_language_config_get(["en"])) book = BookMetadata( provider="hardcover", @@ -68,3 +83,42 @@ class TestReleaseSearchPlan: assert [(v.title, v.languages) for v in plan.grouped_title_variants] == [ ("The Lightning Thief", None), ] + + def test_user_default_languages_beat_the_global_default(self, monkeypatch): + import shelfmark.core.search_plan as sp + + monkeypatch.setattr( + sp.config, + "get", + _book_language_config_get(["en"], user_languages=["de", "en"]), + ) + + book = BookMetadata( + provider="hardcover", + provider_id="123", + title="The Final Empire", + authors=["Brandon Sanderson"], + ) + + assert build_release_search_plan(book).languages == ["en"] + assert build_release_search_plan(book, user_id=7).languages == ["de", "en"] + + def test_explicit_languages_beat_the_user_default(self, monkeypatch): + import shelfmark.core.search_plan as sp + + monkeypatch.setattr( + sp.config, + "get", + _book_language_config_get(["en"], user_languages=["de", "en"]), + ) + + book = BookMetadata( + provider="hardcover", + provider_id="123", + title="The Final Empire", + authors=["Brandon Sanderson"], + ) + + plan = build_release_search_plan(book, languages=["fr"], user_id=7) + + assert plan.languages == ["fr"] diff --git a/tests/core/test_self_user_routes.py b/tests/core/test_self_user_routes.py index abd467df..a782e804 100644 --- a/tests/core/test_self_user_routes.py +++ b/tests/core/test_self_user_routes.py @@ -85,6 +85,7 @@ def test_users_me_edit_context_includes_search_preferences_when_visible(app, use { "SEARCH_MODE": "universal", "METADATA_PROVIDER": "openlibrary", + "BOOK_LANGUAGE": ["de", "en"], }, ) client = _authed_client_for_user(app, user) @@ -102,8 +103,13 @@ def test_users_me_edit_context_includes_search_preferences_when_visible(app, use assert resp.json["searchPreferences"]["tab"] == "search_mode" assert resp.json["searchPreferences"]["effective"]["SEARCH_MODE"]["value"] == "universal" assert resp.json["searchPreferences"]["effective"]["SEARCH_MODE"]["source"] == "user_override" + assert resp.json["searchPreferences"]["effective"]["BOOK_LANGUAGE"]["value"] == ["de", "en"] + assert resp.json["searchPreferences"]["effective"]["BOOK_LANGUAGE"]["source"] == ( + "user_override" + ) assert "SEARCH_MODE" in resp.json["userOverridableKeys"] assert "METADATA_PROVIDER" in resp.json["userOverridableKeys"] + assert "BOOK_LANGUAGE" in resp.json["userOverridableKeys"] assert resp.json["notificationPreferences"] is None assert resp.json["userOverridableKeys"] == sorted(resp.json["userOverridableKeys"]) @@ -170,6 +176,43 @@ def test_users_me_update_accepts_visible_section_settings(app, user_db): assert resp.json["settings"]["DESTINATION"] == "/books/alice" +def test_users_me_update_accepts_book_language_when_search_section_visible(app, user_db): + user = user_db.create_user(username="alice") + client = _authed_client_for_user(app, user) + + with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"): + with patch( + "shelfmark.core.self_user_routes.app_config.get", + side_effect=_visible_sections_config_get(["search"]), + ): + resp = client.put( + "/api/users/me", + json={"settings": {"BOOK_LANGUAGE": ["German", "en"]}}, + ) + + assert resp.status_code == 200 + assert user_db.get_user_settings(user["id"])["BOOK_LANGUAGE"] == ["de", "en"] + + +def test_users_me_update_rejects_book_language_when_search_section_hidden(app, user_db): + user = user_db.create_user(username="alice") + client = _authed_client_for_user(app, user) + + with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"): + with patch( + "shelfmark.core.self_user_routes.app_config.get", + side_effect=_visible_sections_config_get(["delivery"]), + ): + resp = client.put( + "/api/users/me", + json={"settings": {"BOOK_LANGUAGE": ["de"]}}, + ) + + assert resp.status_code == 400 + assert resp.json["error"] == "Some settings are admin-only" + assert user_db.get_user_settings(user["id"]) == {} + + def test_users_me_update_rejects_non_object_settings_payload(app, user_db): user = user_db.create_user(username="alice") client = _authed_client_for_user(app, user)