Refine UI and adjust content type settings (#705)

- Tweak manual search toggle position
- Refinements to the Hardcover list dropdown behavior
- Hide the content type dropdown when a content type is blocked for a
user
- Fixes to Hardcover author parsing to strip out initialed names
- Remove `env_supported=false` for security config options.
This commit is contained in:
Alex
2026-03-05 16:24:03 +00:00
committed by GitHub
parent cce2c10704
commit ba92ad90bc
13 changed files with 408 additions and 169 deletions
+177 -11
View File
@@ -10,6 +10,7 @@ This document lists all configuration options that can be set via environment va
- [General](#general)
- [Search Mode](#search-mode)
- [Downloads](#downloads)
- [Security](#security)
- [Network](#network)
- [Advanced](#advanced)
- [Prowlarr](#prowlarr)
@@ -251,8 +252,8 @@ The release source tab to open by default in the release modal.
| `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` |
| `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` |
| `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` |
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. | string | `{Author} - {Title} ({Year})` |
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
| `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` |
| `BOOKLORE_HOST` | Base URL of your Booklore instance | string | _none_ |
| `BOOKLORE_USERNAME` | Booklore account username | string | _none_ |
@@ -273,8 +274,8 @@ The release source tab to open by default in the release modal.
| `EMAIL_ALLOW_UNVERIFIED_TLS` | Disable TLS certificate verification (not recommended). | boolean | `false` |
| `DESTINATION_AUDIOBOOK` | Directory where downloaded audiobook files are saved. Leave empty to use the Books destination. | string | _none_ |
| `FILE_ORGANIZATION_AUDIOBOOK` | Choose how downloaded audiobook files are named and organized. | string (choice) | `rename` |
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. | string | `{Author} - {Title}` |
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}` |
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}` |
| `HARDLINK_TORRENTS_AUDIOBOOK` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `true` |
| `AUTO_OPEN_DOWNLOADS_SIDEBAR` | Automatically open the downloads sidebar when a new download is queued. | boolean | `false` |
| `DOWNLOAD_TO_BROWSER` | Automatically download completed files to your browser. | boolean | `false` |
@@ -318,7 +319,7 @@ Choose how downloaded book files are named and organized.
**Naming Template**
Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders.
Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
- **Type:** string
- **Default:** `{Author} - {Title} ({Year})`
@@ -327,7 +328,7 @@ Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesP
**Path Template**
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
- **Type:** string
- **Default:** `{Author}/{Title} ({Year})`
@@ -528,7 +529,7 @@ Choose how downloaded audiobook files are named and organized.
**Naming Template**
Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders.
Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
- **Type:** string
- **Default:** `{Author} - {Title}`
@@ -537,7 +538,7 @@ Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subti
**Path Template**
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
- **Type:** string
- **Default:** `{Author}/{Title}`
@@ -592,10 +593,165 @@ How long to keep completed/failed downloads in the queue display.
</details>
## Security
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `AUTH_METHOD` | Select the authentication method for accessing Shelfmark. | string (choice) | `none` |
| `PROXY_AUTH_USER_HEADER` | The HTTP header your proxy uses to pass the authenticated username. | string | `X-Auth-User` |
| `PROXY_AUTH_LOGOUT_URL` | The URL to redirect users to for logging out. Leave empty to disable logout functionality. | string | _empty string_ |
| `PROXY_AUTH_ADMIN_GROUP_HEADER` | Optional: header your proxy uses to pass user groups/roles. | string | `X-Auth-Groups` |
| `PROXY_AUTH_ADMIN_GROUP_NAME` | Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection. | string | _empty string_ |
| `OIDC_DISCOVERY_URL` | OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration. | string | _none_ |
| `OIDC_CLIENT_ID` | OAuth2 client ID from your identity provider. | string | _none_ |
| `OIDC_CLIENT_SECRET` | OAuth2 client secret from your identity provider. | string (secret) | _none_ |
| `OIDC_SCOPES` | OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization. | string | `openid,email,profile` |
| `OIDC_GROUP_CLAIM` | The name of the claim in the ID token that contains user groups. | string | `groups` |
| `OIDC_ADMIN_GROUP` | Users in this group will be given admin access (if enabled below). Leave empty to use database roles only. | string | _empty string_ |
| `OIDC_USE_ADMIN_GROUP` | When enabled, users in the Admin Group are granted admin access. When disabled, admin access is determined solely by database roles. | boolean | `true` |
| `OIDC_AUTO_PROVISION` | Automatically create a user account on first OIDC login. When disabled, users must be pre-created by an admin. | boolean | `true` |
| `OIDC_BUTTON_LABEL` | Custom label for the OIDC sign-in button on the login page. | string | _empty string_ |
<details>
<summary>Detailed descriptions</summary>
#### `AUTH_METHOD`
**Authentication Method**
Select the authentication method for accessing Shelfmark.
- **Type:** string (choice)
- **Default:** `none`
- **Options:** `none` (No Authentication), `builtin` (Local), `proxy` (Proxy Authentication), `oidc` (OIDC (OpenID Connect)), `cwa` (Calibre-Web Database)
#### `PROXY_AUTH_USER_HEADER`
**Proxy Auth User Header**
The HTTP header your proxy uses to pass the authenticated username.
- **Type:** string
- **Default:** `X-Auth-User`
#### `PROXY_AUTH_LOGOUT_URL`
**Proxy Auth Logout URL**
The URL to redirect users to for logging out. Leave empty to disable logout functionality.
- **Type:** string
- **Default:** _empty string_
#### `PROXY_AUTH_ADMIN_GROUP_HEADER`
**Proxy Auth Admin Group Header**
Optional: header your proxy uses to pass user groups/roles.
- **Type:** string
- **Default:** `X-Auth-Groups`
#### `PROXY_AUTH_ADMIN_GROUP_NAME`
**Proxy Auth Admin Group**
Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection.
- **Type:** string
- **Default:** _empty string_
#### `OIDC_DISCOVERY_URL`
**Discovery URL**
OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration.
- **Type:** string
- **Default:** _none_
- **Required:** Yes
#### `OIDC_CLIENT_ID`
**Client ID**
OAuth2 client ID from your identity provider.
- **Type:** string
- **Default:** _none_
- **Required:** Yes
#### `OIDC_CLIENT_SECRET`
**Client Secret**
OAuth2 client secret from your identity provider.
- **Type:** string (secret)
- **Default:** _none_
- **Required:** Yes
#### `OIDC_SCOPES`
**Scopes**
OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization.
- **Type:** string
- **Default:** `openid,email,profile`
#### `OIDC_GROUP_CLAIM`
**Group Claim Name**
The name of the claim in the ID token that contains user groups.
- **Type:** string
- **Default:** `groups`
#### `OIDC_ADMIN_GROUP`
**Admin Group Name**
Users in this group will be given admin access (if enabled below). Leave empty to use database roles only.
- **Type:** string
- **Default:** _empty string_
#### `OIDC_USE_ADMIN_GROUP`
**Use Admin Group for Authorization**
When enabled, users in the Admin Group are granted admin access. When disabled, admin access is determined solely by database roles.
- **Type:** boolean
- **Default:** `true`
#### `OIDC_AUTO_PROVISION`
**Auto-Provision Users**
Automatically create a user account on first OIDC login. When disabled, users must be pre-created by an admin.
- **Type:** boolean
- **Default:** `true`
#### `OIDC_BUTTON_LABEL`
**Login Button Label**
Custom label for the OIDC sign-in button on the login page.
- **Type:** string
- **Default:** _empty string_
</details>
## Network
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `CERTIFICATE_VALIDATION` | Controls SSL/TLS certificate verification for outbound connections. Disable for self-signed certificates on internal services (e.g. OIDC providers, Prowlarr). | string (choice) | `enabled` |
| `CUSTOM_DNS` | DNS provider for domain resolution. 'Auto' rotates through providers on failure. | string (choice) | `auto` |
| `CUSTOM_DNS_MANUAL` | Comma-separated list of DNS server IP addresses (e.g., 8.8.8.8, 1.1.1.1). | string | _none_ |
| `USE_DOH` | Use encrypted DNS queries for improved reliability and privacy. | boolean | `true` |
@@ -609,6 +765,16 @@ How long to keep completed/failed downloads in the queue display.
<details>
<summary>Detailed descriptions</summary>
#### `CERTIFICATE_VALIDATION`
**Certificate Validation**
Controls SSL/TLS certificate verification for outbound connections. Disable for self-signed certificates on internal services (e.g. OIDC providers, Prowlarr).
- **Type:** string (choice)
- **Default:** `enabled`
- **Options:** `enabled` (Enabled (Recommended)), `disabled_local` (Disabled for Local Addresses), `disabled` (Disabled)
#### `CUSTOM_DNS`
**DNS Provider**
@@ -1773,7 +1939,7 @@ Timeout for external bypasser requests in milliseconds.
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `AA_BASE_URL` | Select 'Auto' to try mirrors from your list on startup and fall back on failures. Choosing a specific mirror locks Shelfmark to that mirror (no fallback). | string (choice) | `auto` |
| `AA_MIRROR_URLS` | Editable list of AA mirrors. Used to populate the Primary Mirror dropdown and the order used when Auto is selected. Type a URL and press Enter to add. Order matters for auto-rotation | string | `https://annas-archive.gl,https://annas-archive.li` |
| `AA_MIRROR_URLS` | Editable list of AA mirrors. Used to populate the Primary Mirror dropdown and the order used when Auto is selected. Type a URL and press Enter to add. Order matters for auto-rotation | string | `https://annas-archive.gl,https://annas-archive.pk,https://annas-archive.vg,https://annas-archive.gd` |
| `AA_ADDITIONAL_URLS` | Deprecated. Use Mirrors instead. This is kept for backwards compatibility with existing installs and environment variables. | string | _none_ |
| `LIBGEN_ADDITIONAL_URLS` | Comma-separated list of custom LibGen mirrors to add to the defaults. | string | _none_ |
| `ZLIB_PRIMARY_URL` | Z-Library mirror to use for downloads. | string (choice) | `https://z-lib.fm` |
@@ -1792,7 +1958,7 @@ Select 'Auto' to try mirrors from your list on startup and fall back on failures
- **Type:** string (choice)
- **Default:** `auto`
- **Options:** `auto` (Auto (Recommended)), `https://annas-archive.gl` (annas-archive.gl), `https://annas-archive.li` (annas-archive.li)
- **Options:** `auto` (Auto (Recommended)), `https://annas-archive.gl` (annas-archive.gl), `https://annas-archive.pk` (annas-archive.pk), `https://annas-archive.vg` (annas-archive.vg), `https://annas-archive.gd` (annas-archive.gd)
#### `AA_MIRROR_URLS`
@@ -1801,7 +1967,7 @@ Select 'Auto' to try mirrors from your list on startup and fall back on failures
Editable list of AA mirrors. Used to populate the Primary Mirror dropdown and the order used when Auto is selected. Type a URL and press Enter to add. Order matters for auto-rotation
- **Type:** string
- **Default:** `https://annas-archive.gl,https://annas-archive.li`
- **Default:** `https://annas-archive.gl,https://annas-archive.pk,https://annas-archive.vg,https://annas-archive.gd`
#### `AA_ADDITIONAL_URLS`
+3 -2
View File
@@ -222,6 +222,7 @@ def generate_env_docs() -> str:
"""Generate markdown documentation for all environment variables."""
# Import settings modules to ensure all settings are registered
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
import shelfmark.release_sources.irc.settings # noqa: F401
import shelfmark.release_sources.prowlarr.settings # noqa: F401
import shelfmark.metadata_providers.hardcover # noqa: F401
@@ -310,7 +311,7 @@ def generate_env_docs() -> str:
def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]:
"""Generate documentation for a single settings tab."""
from shelfmark.core.settings_registry import ActionButton, HeadingField
from shelfmark.core.settings_registry import ActionButton, CustomComponentField, HeadingField
lines = []
@@ -327,7 +328,7 @@ def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]:
env_fields = []
for field in tab.fields:
# Skip non-value fields
if isinstance(field, (ActionButton, HeadingField)):
if isinstance(field, (ActionButton, CustomComponentField, HeadingField)):
continue
# Skip fields that don't support ENV vars
+21 -19
View File
@@ -29,12 +29,8 @@ def _auth_condition(auth_method: str) -> dict[str, str]:
return {"field": "AUTH_METHOD", "value": auth_method}
def _ui_field(factory: Callable[..., Any], **kwargs: Any) -> Any:
return factory(env_supported=False, **kwargs)
def _auth_ui_field(factory: Callable[..., Any], auth_method: str, **kwargs: Any) -> Any:
return _ui_field(factory, show_when=_auth_condition(auth_method), **kwargs)
def _auth_field(factory: Callable[..., Any], auth_method: str, **kwargs: Any) -> Any:
return factory(show_when=_auth_condition(auth_method), **kwargs)
def _migrate_security_settings() -> None:
@@ -80,22 +76,16 @@ def security_settings():
{"label": "Local", "value": "builtin"},
{"label": "Proxy Authentication", "value": "proxy"},
{"label": "OIDC (OpenID Connect)", "value": "oidc"},
{"label": "Calibre-Web Database", "value": "cwa"},
]
if cwa_db_available:
auth_method_options.append({"label": "Calibre-Web Database", "value": "cwa"})
auth_method_description = "Select the authentication method for accessing Shelfmark."
if not cwa_db_available:
auth_method_description += " Calibre-Web database option requires mounting your Calibre-Web app.db to /auth/app.db."
fields = [
SelectField(
key="AUTH_METHOD",
label="Authentication Method",
description=auth_method_description,
description="Select the authentication method for accessing Shelfmark.",
options=auth_method_options,
default="none",
env_supported=False,
),
CustomComponentField(
key="builtin_admin_requirement",
@@ -112,6 +102,18 @@ def security_settings():
label="A local admin account is required before OIDC can be enabled.",
show_when=_auth_condition("oidc"),
),
*([] if cwa_db_available else [
CustomComponentField(
key="cwa_db_missing",
component="oidc_admin_hint",
label=(
"Calibre-Web database not detected. Mount your app.db to "
"/auth/app.db to enable this method. Authentication will fall "
"back to none until the database is available."
),
show_when=_auth_condition("cwa"),
),
]),
ActionButton(
key="open_users_tab",
label="Go to Users",
@@ -119,7 +121,7 @@ def security_settings():
style="primary",
show_when={"field": "AUTH_METHOD", "value": ["builtin", "oidc"]},
),
_auth_ui_field(
_auth_field(
TextField,
"proxy",
key="PROXY_AUTH_USER_HEADER",
@@ -128,7 +130,7 @@ def security_settings():
placeholder="e.g. X-Auth-User",
default="X-Auth-User",
),
_auth_ui_field(
_auth_field(
TextField,
"proxy",
key="PROXY_AUTH_LOGOUT_URL",
@@ -137,7 +139,7 @@ def security_settings():
placeholder="https://myauth.example.com/logout",
default="",
),
_auth_ui_field(
_auth_field(
TextField,
"proxy",
key="PROXY_AUTH_ADMIN_GROUP_HEADER",
@@ -146,7 +148,7 @@ def security_settings():
placeholder="e.g. X-Auth-Groups",
default="X-Auth-Groups",
),
_auth_ui_field(
_auth_field(
TextField,
"proxy",
key="PROXY_AUTH_ADMIN_GROUP_NAME",
@@ -255,7 +257,7 @@ def security_settings():
},
),
]
fields.extend(_auth_ui_field(factory, "oidc", **spec) for factory, spec in oidc_specs)
fields.extend(_auth_field(factory, "oidc", **spec) for factory, spec in oidc_specs)
fields.append(
ActionButton(
key="test_oidc",
+26 -8
View File
@@ -189,6 +189,16 @@ def _is_probably_series_position(subtitle: str) -> bool:
if normalized in {"a novel", "a novella", "a story", "a memoir"}:
return True
# Descriptive subtitles like "A [Name] Novel", "An [Name] Mystery", etc.
genre_words = (
"novel", "novella", "story", "memoir", "tale", "thriller", "mystery",
"romance", "adventure", "epic", "saga", "chronicle", "fantasy",
"novel-in-stories",
)
genre_pattern = "|".join(re.escape(w) for w in genre_words)
if re.match(rf"^an?\s+.+\s+({genre_pattern})$", normalized):
return True
return False
@@ -201,10 +211,12 @@ def _simplify_author_for_search(author: str) -> Optional[str]:
"""Return a looser author string for indexer searches.
Primary goal: reduce mismatch between metadata providers and indexers.
Indexers store author names inconsistently ("R.A.", "R. A.", "Salvatore, R.A.")
so initials add noise and hurt recall.
Heuristics (intentionally conservative):
- Remove middle initials (e.g. "Robert R. McCammon" -> "Robert McCammon")
- Remove standalone middle names that are just an initial or initial+dot
Heuristics:
- Strip all initials (single or compound), keeping only full names
e.g. "R. A. Salvatore" -> "Salvatore", "George R.R. Martin" -> "George Martin"
- Preserve suffixes like "Jr."/"Sr."/"III" as they sometimes matter
"""
if not author:
@@ -238,15 +250,13 @@ def _simplify_author_for_search(author: str) -> Optional[str]:
simplified.append(t)
continue
# Drop middle initials like "R." or "R"
is_initial = re.match(r"^[A-Za-z]\.?$", t) is not None
is_middle_token = 0 < idx < (len(tokens) - 1)
if is_middle_token and is_initial:
# Drop all initials: "R.", "R", "R.R.", "J.K.", etc.
if re.match(r"^[A-Za-z]$|^([A-Za-z]\.)+[A-Za-z]?$", t):
continue
simplified.append(t)
if len(simplified) < 2:
if not simplified:
return None
candidate = " ".join(simplified).strip()
@@ -288,6 +298,14 @@ def _compute_search_title(
if normalized_subtitle and normalized_subtitle.lower() == normalized_title.lower():
normalized_subtitle = ""
# If subtitle is noise, strip it from the title and use just the prefix.
if normalized_subtitle and _is_probably_series_position(normalized_subtitle):
match = re.match(r"^(.+?)\s*:\s*(.+)$", normalized_title)
if match:
suffix = _strip_parenthetical_suffix(match.group(2).strip())
if normalized_subtitle.lower() == suffix.lower() or normalized_subtitle.lower() in suffix.lower():
return match.group(1).strip()
# Prefer subtitle when it looks like the real title.
if normalized_subtitle and not _is_probably_series_position(normalized_subtitle):
match = re.match(r"^(.+?)\s*:\s*(.+)$", normalized_title)
+50 -2
View File
@@ -227,6 +227,27 @@ function App() {
const requestRoleIsAdmin = requestPolicy ? Boolean(requestPolicy.is_admin) : false;
// Compute which content types this user is allowed to search for.
// If a content type's default policy mode is 'blocked', hide it from the dropdown.
const allowedContentTypes = useMemo((): ContentType[] => {
// If policy not loaded yet or user is admin, allow everything
if (!requestPolicy || requestRoleIsAdmin || !requestsPolicyEnabled) {
return ['ebook', 'audiobook'];
}
const types: ContentType[] = [];
if (getDefaultMode('ebook') !== 'blocked') types.push('ebook');
if (getDefaultMode('audiobook') !== 'blocked') types.push('audiobook');
// If both are blocked, still show both (user can see results, just can't download)
return types.length > 0 ? types : ['ebook', 'audiobook'];
}, [requestPolicy, requestRoleIsAdmin, requestsPolicyEnabled, getDefaultMode]);
// Auto-switch content type if the current selection is blocked
useEffect(() => {
if (allowedContentTypes.length > 0 && !allowedContentTypes.includes(contentType)) {
setContentType(allowedContentTypes[0]);
}
}, [allowedContentTypes, contentType]);
const {
isLoading: isRequestsLoading,
cancelRequest: cancelUserRequest,
@@ -356,6 +377,7 @@ function App() {
handleSortChange,
searchFieldValues,
updateSearchFieldValue,
searchFieldLabels,
// Pagination (universal mode)
hasMore,
isLoadingMore,
@@ -1385,6 +1407,13 @@ function App() {
});
}, [config?.metadata_search_fields, searchFieldValues]);
const activeListLabel = useMemo(() => {
if (!isListBrowsing) return '';
const field = (config?.metadata_search_fields ?? [])
.find((f) => f.type === 'DynamicSelectSearchField' && searchFieldValues[f.key]);
return field ? (searchFieldLabels[field.key] || '') : '';
}, [isListBrowsing, config?.metadata_search_fields, searchFieldValues, searchFieldLabels]);
// Reset manual search if policy changes to disallow it
useEffect(() => {
if (!manualSearchAllowed && isManualSearch) {
@@ -1392,6 +1421,21 @@ function App() {
}
}, [manualSearchAllowed, isManualSearch]);
const handleManualSearchToggle = useCallback(() => {
setIsManualSearch(prev => {
if (!prev) {
// Turning on: clear any dynamic select field values (e.g. list selection)
const dynamicKeys = (config?.metadata_search_fields ?? [])
.filter((f) => f.type === 'DynamicSelectSearchField')
.map((f) => f.key);
for (const key of dynamicKeys) {
updateSearchFieldValue(key, '');
}
}
return !prev;
});
}, [config?.metadata_search_fields, updateSearchFieldValue]);
// Unified search dispatch: intercepts manual search mode, otherwise runs normal search
const handleSearchDispatch = useCallback(() => {
if (isManualSearch) {
@@ -1474,8 +1518,10 @@ function App() {
onRemoveToast={removeToast}
contentType={contentType}
onContentTypeChange={setContentType}
allowedContentTypes={allowedContentTypes}
isManualSearch={isManualSearch}
searchDisabled={isListBrowsing}
activeListLabel={activeListLabel}
/>
</div>
@@ -1510,7 +1556,7 @@ function App() {
onSearchFieldChange={updateSearchFieldValue}
onSubmit={handleSearchDispatch}
isManualSearch={isManualSearch}
onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined}
onManualSearchToggle={manualSearchAllowed ? handleManualSearchToggle : undefined}
/>
<main
@@ -1540,9 +1586,11 @@ function App() {
onSearchFieldChange={updateSearchFieldValue}
contentType={contentType}
onContentTypeChange={setContentType}
allowedContentTypes={allowedContentTypes}
isManualSearch={isManualSearch}
onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined}
onManualSearchToggle={manualSearchAllowed ? handleManualSearchToggle : undefined}
searchDisabled={isListBrowsing}
activeListLabel={activeListLabel}
/>
<ResultsSection
+65 -43
View File
@@ -21,7 +21,7 @@ interface AdvancedFiltersProps {
// Universal mode props
metadataSearchFields?: MetadataSearchField[];
searchFieldValues?: Record<string, string | number | boolean>;
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
onSearchFieldChange?: (key: string, value: string | number | boolean, label?: string) => void;
// Submit handler for Enter key
onSubmit?: () => void;
// Manual search mode (universal only)
@@ -85,50 +85,72 @@ export const AdvancedFilters = ({
// If no fields and no toggle available, don't show the section
if (!hasProviderFields && !onManualSearchToggle) return null;
const manualToggle = onManualSearchToggle ? (
<div className="space-y-1.5">
<div className="flex items-start justify-between gap-2">
<label className="text-sm font-medium">Manual search</label>
</div>
<div>
<ToggleSwitch
checked={isManualSearch}
onChange={() => onManualSearchToggle()}
color="emerald"
/>
</div>
<p className="text-xs"><span className="opacity-60">Search release sources directly</span></p>
</div>
) : null;
// When formClassName is provided (initial state), the form carries its own padding;
// otherwise use the default positioning classes for the header-bar state.
const wrapperClassName = formClassName
? 'px-2'
: 'px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]';
const universalForm = (
<form
id="search-filters"
className={
formClassName ??
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
}
>
{manualToggle && <div className="col-span-full">{manualToggle}</div>}
{!isManualSearch && metadataSearchFields.map((field) => (
<div key={field.key}>
{field.type !== 'CheckboxSearchField' && (
<label htmlFor={`${field.key}-input`} className="block text-sm mb-1 opacity-80">
{field.label}
</label>
)}
<SearchFieldRenderer
field={field}
value={searchFieldValues[field.key] ?? (field.type === 'CheckboxSearchField' ? false : '')}
onChange={(value) => onSearchFieldChange?.(field.key, value)}
onSubmit={onSubmit}
/>
{field.description && (
<p className="text-xs mt-1 opacity-60">{field.description}</p>
)}
<div className={wrapperClassName}>
{onManualSearchToggle && (
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium opacity-70">Search Options</span>
<label className="flex items-center gap-2 cursor-pointer">
<span className="text-xs opacity-60">Manual search</span>
<ToggleSwitch
checked={isManualSearch}
onChange={() => onManualSearchToggle()}
color="emerald"
/>
</label>
</div>
))}
</form>
)}
{isManualSearch && (
<p className="text-xs opacity-50 mb-3">
Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates.
</p>
)}
{!isManualSearch && metadataSearchFields.length > 0 && (
<form
id="search-filters"
className={
formClassName ??
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
}
>
{metadataSearchFields.map((field) => (
<div key={field.key}>
{field.type !== 'CheckboxSearchField' && (
<div className="flex items-center justify-between mb-1">
<label htmlFor={`${field.key}-input`} className="text-sm opacity-80">
{field.label}
</label>
{field.type === 'DynamicSelectSearchField' && searchFieldValues[field.key] && (
<button
type="button"
onClick={() => onSearchFieldChange?.(field.key, '')}
className="text-xs font-medium text-sky-500 hover:text-sky-400 transition-colors"
>
Clear
</button>
)}
</div>
)}
<SearchFieldRenderer
field={field}
value={searchFieldValues[field.key] ?? (field.type === 'CheckboxSearchField' ? false : '')}
onChange={(value, label) => onSearchFieldChange?.(field.key, value, label)}
onSubmit={onSubmit}
/>
{field.description && (
<p className="text-xs mt-1 opacity-60">{field.description}</p>
)}
</div>
))}
</form>
)}
</div>
);
const wrappedUniversalForm = renderWrapper ? (
@@ -148,7 +170,7 @@ export const AdvancedFilters = ({
id="search-filters"
className={
formClassName ??
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[calc(50vw+4rem)]'
}
>
<div>
+6
View File
@@ -39,8 +39,10 @@ interface HeaderProps {
onRemoveToast?: (id: string) => void;
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
allowedContentTypes?: ContentType[];
isManualSearch?: boolean;
searchDisabled?: boolean;
activeListLabel?: string;
}
export const Header = forwardRef<HeaderHandle, HeaderProps>(({
@@ -71,8 +73,10 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
onRemoveToast,
contentType = 'ebook',
onContentTypeChange,
allowedContentTypes,
isManualSearch = false,
searchDisabled = false,
activeListLabel,
}, ref) => {
const activityBadge = getActivityBadgeState(statusCounts, isAdmin);
const settingsEnabled = canAccessSettings ?? isAdmin;
@@ -656,8 +660,10 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
isLoading={isLoading}
contentType={contentType}
onContentTypeChange={onContentTypeChange}
allowedContentTypes={allowedContentTypes}
isManualSearch={isManualSearch}
disabled={searchDisabled}
activeListLabel={activeListLabel}
/>
</div>
</div>
+16 -8
View File
@@ -24,9 +24,11 @@ interface SearchBarProps {
// Content type selector props
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
allowedContentTypes?: ContentType[];
// Manual search mode
isManualSearch?: boolean;
disabled?: boolean;
activeListLabel?: string;
}
export interface SearchBarHandle {
@@ -54,8 +56,10 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
enterKeyHint = 'search',
contentType = 'ebook',
onContentTypeChange,
allowedContentTypes,
isManualSearch = false,
disabled = false,
activeListLabel,
}, ref) => {
const { searchMode, isUniversalMode } = useSearchMode();
const inputRef = useRef<HTMLInputElement>(null);
@@ -65,14 +69,18 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
// Content type dropdown state
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const showContentTypeSelector = isUniversalMode && !!onContentTypeChange;
const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1;
const showContentTypeSelector = isUniversalMode && !!onContentTypeChange && hasMultipleContentTypes;
// Dynamic placeholder based on content type and manual search
const effectivePlaceholder = isManualSearch
? 'Search releases directly...'
: showContentTypeSelector
? (contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks')
: placeholder;
// Dynamic placeholder based on content type, manual search, and list browsing
const isContentTypeAware = isUniversalMode && !!onContentTypeChange;
const effectivePlaceholder = activeListLabel
? `${activeListLabel} selected`
: isManualSearch
? 'Search releases directly...'
: isContentTypeAware
? (contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks')
: placeholder;
// Close dropdown on click outside or escape
useEffect(() => {
@@ -162,7 +170,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
<div
className="flex items-stretch rounded-full border"
style={{
background: 'var(--bg-soft)',
background: disabled ? 'var(--bg)' : 'var(--bg-soft)',
borderColor: 'var(--border-muted)',
}}
>
@@ -21,13 +21,15 @@ interface SearchSectionProps {
// Universal mode props
metadataSearchFields?: MetadataSearchField[];
searchFieldValues?: Record<string, string | number | boolean>;
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
onSearchFieldChange?: (key: string, value: string | number | boolean, label?: string) => void;
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
allowedContentTypes?: ContentType[];
// Manual search mode (universal only)
isManualSearch?: boolean;
onManualSearchToggle?: () => void;
searchDisabled?: boolean;
activeListLabel?: string;
}
export const SearchSection = ({
@@ -49,9 +51,11 @@ export const SearchSection = ({
onSearchFieldChange,
contentType = 'ebook',
onContentTypeChange,
allowedContentTypes,
isManualSearch = false,
onManualSearchToggle,
searchDisabled = false,
activeListLabel,
}: SearchSectionProps) => {
const { searchMode } = useSearchMode();
@@ -93,8 +97,10 @@ export const SearchSection = ({
onAdvancedToggle={onAdvancedToggle}
contentType={contentType}
onContentTypeChange={onContentTypeChange}
allowedContentTypes={allowedContentTypes}
isManualSearch={isManualSearch}
disabled={searchDisabled}
activeListLabel={activeListLabel}
/>
<AdvancedFilters
visible={showAdvanced}
@@ -103,7 +109,7 @@ export const SearchSection = ({
supportedFormats={supportedFormats}
filters={advancedFilters}
onFiltersChange={onAdvancedFiltersChange}
formClassName="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2"
formClassName="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
renderWrapper={form => form}
metadataSearchFields={metadataSearchFields}
searchFieldValues={searchFieldValues}
@@ -2,26 +2,30 @@ import { useEffect, useState } from 'react';
import { getAdminUsers } from '../../../services/api';
import { CustomSettingsFieldRendererProps } from './types';
const ADMIN_CHECK_KEYS = new Set(['builtin_admin_requirement', 'oidc_admin_requirement']);
export const OidcAdminHint = ({ field }: CustomSettingsFieldRendererProps) => {
const [needsAdmin, setNeedsAdmin] = useState<boolean | null>(null);
const needsAdminCheck = ADMIN_CHECK_KEYS.has(field.key);
const [visible, setVisible] = useState(!needsAdminCheck);
useEffect(() => {
if (!needsAdminCheck) return;
let cancelled = false;
getAdminUsers()
.then((users) => {
if (!cancelled) {
setNeedsAdmin(!users.some(u => u.role === 'admin' && u.auth_source === 'builtin'));
setVisible(!users.some(u => u.role === 'admin' && u.auth_source === 'builtin'));
}
})
.catch(() => {
if (!cancelled) {
setNeedsAdmin(true);
setVisible(true);
}
});
return () => { cancelled = true; };
}, []);
}, [needsAdminCheck]);
if (!needsAdmin) return null;
if (!visible) return null;
return (
<div className="text-sm px-3 py-2 rounded-lg bg-amber-500/15 text-amber-700 dark:text-amber-300">
@@ -5,48 +5,25 @@ import { DynamicFieldOption, fetchFieldOptions } from '../../services/api';
interface DynamicDropdownProps {
endpoint: string;
value: string;
onChange: (value: string) => void;
onChange: (value: string, label?: string) => void;
placeholder?: string;
allLabel?: string;
}
const GROUP_HEADER_PREFIX = '__group__';
const buildOptions = (
options: DynamicFieldOption[],
allLabel: string
): DropdownListOption[] => {
const built: DropdownListOption[] = [{ value: '', label: allLabel }];
let previousGroup: string | null = null;
options.forEach((option, index) => {
if (option.group && option.group !== previousGroup) {
previousGroup = option.group;
built.push({
value: `${GROUP_HEADER_PREFIX}${option.group}:${index}`,
label: option.group,
disabled: true,
});
} else if (!option.group) {
previousGroup = null;
}
built.push({
value: option.value,
label: option.label,
description: option.description,
});
});
return built;
return options.map((option) => ({
value: option.value,
label: option.label,
description: option.description,
}));
};
export const DynamicDropdown = ({
endpoint,
value,
onChange,
placeholder = 'Select an option',
allLabel = 'All',
placeholder = 'Select...',
}: DynamicDropdownProps) => {
const [options, setOptions] = useState<DynamicFieldOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -87,28 +64,20 @@ export const DynamicDropdown = ({
const dropdownOptions = useMemo(() => {
if (isLoading) {
return [
{ value: '', label: allLabel },
{ value: '__loading', label: 'Loading...', disabled: true },
];
return [{ value: '__loading', label: 'Loading...', disabled: true }];
}
if (loadError) {
return [
{ value: '', label: allLabel },
{ value: '__error', label: loadError, disabled: true },
];
return [{ value: '__error', label: loadError, disabled: true }];
}
return buildOptions(options, allLabel);
}, [allLabel, isLoading, loadError, options]);
return buildOptions(options);
}, [isLoading, loadError, options]);
const handleChange = (nextValue: string[] | string) => {
const normalized = Array.isArray(nextValue) ? nextValue[0] ?? '' : nextValue;
if (normalized.startsWith(GROUP_HEADER_PREFIX)) {
return;
}
onChange(normalized);
const match = options.find((opt) => opt.value === normalized);
onChange(normalized, match?.label);
};
return (
@@ -6,7 +6,7 @@ import { DynamicDropdown } from './DynamicDropdown';
interface SearchFieldRendererProps {
field: MetadataSearchField;
value: string | number | boolean;
onChange: (value: string | number | boolean) => void;
onChange: (value: string | number | boolean, label?: string) => void;
onSubmit?: () => void;
}
@@ -104,9 +104,8 @@ export const SearchFieldRenderer = ({ field, value, onChange, onSubmit }: Search
<DynamicDropdown
endpoint={field.options_endpoint}
value={(value as string) ?? ''}
onChange={(v) => onChange(v)}
placeholder={field.placeholder || 'Select an option'}
allLabel="All"
onChange={(v, label) => onChange(v, label)}
placeholder={field.placeholder || 'Select...'}
/>
);
+11 -21
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useState, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Book, AppConfig, AdvancedFilterState, ContentType } from '../types';
import { searchBooks, searchMetadata, AuthenticationError } from '../services/api';
@@ -41,7 +41,8 @@ interface UseSearchReturn {
resetSortFilter: () => void;
// Universal mode search field values
searchFieldValues: SearchFieldValues;
updateSearchFieldValue: (key: string, value: string | number | boolean) => void;
updateSearchFieldValue: (key: string, value: string | number | boolean, label?: string) => void;
searchFieldLabels: Record<string, string>;
// Pagination (universal mode only)
hasMore: boolean;
isLoadingMore: boolean;
@@ -70,6 +71,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
// Universal mode: provider-specific search field values
const [searchFieldValues, setSearchFieldValues] = useState<SearchFieldValues>({});
const [searchFieldLabels, setSearchFieldLabels] = useState<Record<string, string>>({});
// Pagination state (universal mode only)
const [currentPage, setCurrentPage] = useState(1);
@@ -83,31 +85,18 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
sort: string;
fieldValues: SearchFieldValues;
} | null>(null);
const previousContentTypeRef = useRef<ContentType>(contentType);
// Switching media type starts a new search session.
useEffect(() => {
if (previousContentTypeRef.current === contentType) {
return;
}
previousContentTypeRef.current = contentType;
setBooks([]);
setLastSearchQuery('');
setSearchFieldValues({});
setHasMore(false);
setTotalFound(0);
setCurrentPage(1);
lastSearchParamsRef.current = null;
onSearchReset?.();
}, [contentType, onSearchReset]);
const updateAdvancedFilters = useCallback((updates: Partial<AdvancedFilterState>) => {
setAdvancedFilters(prev => ({ ...prev, ...updates }));
}, []);
const updateSearchFieldValue = useCallback((key: string, value: string | number | boolean) => {
const updateSearchFieldValue = useCallback((key: string, value: string | number | boolean, label?: string) => {
setSearchFieldValues(prev => ({ ...prev, [key]: value }));
if (label !== undefined) {
setSearchFieldLabels(prev => ({ ...prev, [key]: label }));
} else if (!value) {
setSearchFieldLabels(prev => { const next = { ...prev }; delete next[key]; return next; });
}
}, []);
const resetSortFilter = useCallback(() => {
@@ -319,6 +308,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
// Universal mode search field values
searchFieldValues,
updateSearchFieldValue,
searchFieldLabels,
// Pagination (universal mode only)
hasMore,
isLoadingMore,