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)