feat(search): let each user pick their own default book languages (#1255)

## Why

`BOOK_LANGUAGE` is a per-reader property, not a per-instance one. On a
shared install one household member searches in German while another
wants English and German — today whoever changes the setting changes it
for everyone, and the only escape is re-picking languages in the filter
on every single search.

The per-user override machinery already carries `SEARCH_MODE`, the
metadata providers and the default release sources, so the language
default mostly had to opt into it.

## What changed

**The field.** `BOOK_LANGUAGE` becomes `user_overridable` and moves from
the **General** tab to **Search Mode**, next to the other
user-overridable search defaults (per
[review](https://github.com/calibrain/shelfmark/pull/1255#issuecomment-5391189094)
— the first version had the Search section span two tabs, this one
doesn't). Admins set it per user in the user editor, users set it in
**My Account → Search Preferences**, and the Search Mode tab carries the
usual "N users override this" summary.

**No migration for the move.** `general` and `search_mode` both persist
into `settings.json`, and a field's value is resolved through
`load_config_file(tab)` for the tab it's declared on — so an install
that already stores `BOOK_LANGUAGE` keeps its value. Checked against a
`settings.json` written while the field still lived on General: the
stored value resolves unchanged, a fresh install still gets `["en"]`,
and `BOOK_LANGUAGE` in the environment still overrides both.

**The two places the default is read.**

- `/api/config` seeds the frontend's language filter, so it now resolves
`BOOK_LANGUAGE` for the session user.
- `build_release_search_plan` falls back to the default whenever a
request carries no language filter — which is exactly what the filter's
"Default" option sends. It takes an optional `user_id`, passed by
`/api/releases` from the session and by the Prowlarr retry path from
`task.user_id`, so a retry re-searches in the languages of whoever
queued the download.

**Validation.** Overrides go through `normalize_language()`, so
`"German"`, `"ger"` and `"de"` all store as `de`, and an unknown
language is rejected with a message naming it instead of being silently
searched for. An empty list stays an empty list (a deliberate "no
default filter"), `null` clears the override as everywhere else, and ENV
still wins: with `BOOK_LANGUAGE` set in the environment the field
reports `fromEnv` and overrides are ignored.

**Scope.** Only the language default becomes overridable. The two format
lists left behind under "Default Search Filters" stay admin-only — they
describe what the library and its post-processing accept, not what a
reader wants to read. There's a test pinning that.

## Verification

- 2681 unit tests pass (2670 before, 11 added)
- `ruff check`, `ruff format`, `basedpyright` over backend and tests,
and `vulture` all clean; frontend lint, format, typecheck and 126 unit
tests clean
- `docs/environment-variables.md` regenerated via
`scripts/generate_env_docs.py` (the `BOOK_LANGUAGE` row follows the
field into the Search Mode section)
- Manually against a two-user instance with builtin auth (first round,
before the tab move): with user A on German and user B on
English+German, `/api/config` returns each reader their own
`default_language` and an unfiltered `/api/releases` plans the matching
languages; an admin can set and read the same override for another user;
clearing it falls back to the global value; a stray `"klingon"` is
rejected; and `BOOK_LANGUAGE` in the environment overrides both users
with the field marked `fromEnv`
- After the tab move I re-ran the suites above plus the
stored-value/fresh-install/ENV check described under "No migration for
the move"; the behaviour it exercises is what the move could have broken

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
This commit is contained in:
helgehelge123
2026-08-24 17:57:21 -04:00
committed by GitHub
co-authored by Claude Opus 5 CaliBrain
parent a5595cf9f1
commit 463ef49ac3
21 changed files with 382 additions and 40 deletions
+11 -9
View File
@@ -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`
</details>
## 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`
+10 -1
View File
@@ -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.
+11 -7
View File
@@ -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",
+27
View File
@@ -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":
+1
View File
@@ -93,6 +93,7 @@ def validate_user_settings(
continue
if key in {
"SEARCH_MODE",
"BOOK_LANGUAGE",
"METADATA_PROVIDER",
"METADATA_PROVIDER_AUDIOBOOK",
"DEFAULT_RELEASE_SOURCE",
+10 -4
View File
@@ -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:
+4 -1
View File
@@ -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()
@@ -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()
@@ -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 (
@@ -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 = ({
</FieldWrapper>
)}
{canOverrideBookLanguage && (
<FieldWrapper
field={bookLanguageField}
resetAction={
isBookLanguageOverridden
? {
disabled: Boolean(bookLanguageField.fromEnv),
onClick: () => resetKeys(['BOOK_LANGUAGE']),
}
: undefined
}
>
<MultiSelectField
field={bookLanguageField}
value={bookLanguageValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, BOOK_LANGUAGE: value }))}
disabled={Boolean(bookLanguageField.fromEnv)}
/>
</FieldWrapper>
)}
{effectiveSearchMode === 'universal' && canOverrideMetadataProvider && (
<FieldWrapper
field={metadataProviderField}
@@ -31,6 +31,13 @@ export const toNormalizedLowercaseTextValue = (value: unknown): string => {
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 '';
@@ -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;
+11
View File
@@ -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
+10
View File
@@ -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
+42
View File
@@ -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
+3
View File
@@ -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):
+22
View File
@@ -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"]
+7 -1
View File
@@ -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",
@@ -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):
+57 -3
View File
@@ -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"]
+43
View File
@@ -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)