mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 11:41:12 +01:00
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:
co-authored by
Claude Opus 5
CaliBrain
parent
a5595cf9f1
commit
463ef49ac3
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -93,6 +93,7 @@ def validate_user_settings(
|
||||
continue
|
||||
if key in {
|
||||
"SEARCH_MODE",
|
||||
"BOOK_LANGUAGE",
|
||||
"METADATA_PROVIDER",
|
||||
"METADATA_PROVIDER_AUDIOBOOK",
|
||||
"DEFAULT_RELEASE_SOURCE",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user