From ba92ad90bc9d22ca21a2cb20aa2579f47aceda49 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:24:03 +0000 Subject: [PATCH] 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. --- docs/environment-variables.md | 188 +++++++++++++++++- scripts/generate_env_docs.py | 5 +- shelfmark/config/security.py | 40 ++-- shelfmark/metadata_providers/hardcover.py | 34 +++- src/frontend/src/App.tsx | 52 ++++- .../src/components/AdvancedFilters.tsx | 108 ++++++---- src/frontend/src/components/Header.tsx | 6 + src/frontend/src/components/SearchBar.tsx | 24 ++- src/frontend/src/components/SearchSection.tsx | 10 +- .../settings/customFields/OidcAdminHint.tsx | 14 +- .../src/components/shared/DynamicDropdown.tsx | 57 ++---- .../components/shared/SearchFieldRenderer.tsx | 7 +- src/frontend/src/hooks/useSearch.ts | 32 +-- 13 files changed, 408 insertions(+), 169 deletions(-) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index e2d08c23..d2fc92ba 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -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. +## 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_ | + +
+Detailed descriptions + +#### `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_ + +
+ ## 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.
Detailed descriptions +#### `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` diff --git a/scripts/generate_env_docs.py b/scripts/generate_env_docs.py index 1f1c1961..98430728 100755 --- a/scripts/generate_env_docs.py +++ b/scripts/generate_env_docs.py @@ -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 diff --git a/shelfmark/config/security.py b/shelfmark/config/security.py index ccce680d..fee39944 100644 --- a/shelfmark/config/security.py +++ b/shelfmark/config/security.py @@ -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", diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py index 98623f9e..7446b63f 100644 --- a/shelfmark/metadata_providers/hardcover.py +++ b/shelfmark/metadata_providers/hardcover.py @@ -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) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index dacd30e9..0b19dbff 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -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} /> @@ -1510,7 +1556,7 @@ function App() { onSearchFieldChange={updateSearchFieldValue} onSubmit={handleSearchDispatch} isManualSearch={isManualSearch} - onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined} + onManualSearchToggle={manualSearchAllowed ? handleManualSearchToggle : undefined} />
setIsManualSearch(prev => !prev) : undefined} + onManualSearchToggle={manualSearchAllowed ? handleManualSearchToggle : undefined} searchDisabled={isListBrowsing} + activeListLabel={activeListLabel} /> ; - 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 ? ( -
-
- -
-
- onManualSearchToggle()} - color="emerald" - /> -
-

Search release sources directly

-
- ) : 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 = ( -
- {manualToggle &&
{manualToggle}
} - {!isManualSearch && metadataSearchFields.map((field) => ( -
- {field.type !== 'CheckboxSearchField' && ( - - )} - onSearchFieldChange?.(field.key, value)} - onSubmit={onSubmit} - /> - {field.description && ( -

{field.description}

- )} +
+ {onManualSearchToggle && ( +
+ Search Options +
- ))} - + )} + {isManualSearch && ( +

+ Manual search queries release sources directly. Some sources may return limited metadata, which can affect file naming templates. +

+ )} + {!isManualSearch && metadataSearchFields.length > 0 && ( +
+ {metadataSearchFields.map((field) => ( +
+ {field.type !== 'CheckboxSearchField' && ( +
+ + {field.type === 'DynamicSelectSearchField' && searchFieldValues[field.key] && ( + + )} +
+ )} + onSearchFieldChange?.(field.key, value, label)} + onSubmit={onSubmit} + /> + {field.description && ( +

{field.description}

+ )} +
+ ))} +
+ )} +
); 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)]' } >
diff --git a/src/frontend/src/components/Header.tsx b/src/frontend/src/components/Header.tsx index accc3536..018257fa 100644 --- a/src/frontend/src/components/Header.tsx +++ b/src/frontend/src/components/Header.tsx @@ -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(({ @@ -71,8 +73,10 @@ export const Header = forwardRef(({ 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(({ isLoading={isLoading} contentType={contentType} onContentTypeChange={onContentTypeChange} + allowedContentTypes={allowedContentTypes} isManualSearch={isManualSearch} disabled={searchDisabled} + activeListLabel={activeListLabel} />
diff --git a/src/frontend/src/components/SearchBar.tsx b/src/frontend/src/components/SearchBar.tsx index 39d5754e..d0f888b7 100644 --- a/src/frontend/src/components/SearchBar.tsx +++ b/src/frontend/src/components/SearchBar.tsx @@ -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(({ enterKeyHint = 'search', contentType = 'ebook', onContentTypeChange, + allowedContentTypes, isManualSearch = false, disabled = false, + activeListLabel, }, ref) => { const { searchMode, isUniversalMode } = useSearchMode(); const inputRef = useRef(null); @@ -65,14 +69,18 @@ export const SearchBar = forwardRef(({ // 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(({
diff --git a/src/frontend/src/components/SearchSection.tsx b/src/frontend/src/components/SearchSection.tsx index cb3b341c..aa149862 100644 --- a/src/frontend/src/components/SearchSection.tsx +++ b/src/frontend/src/components/SearchSection.tsx @@ -21,13 +21,15 @@ interface SearchSectionProps { // Universal mode props metadataSearchFields?: MetadataSearchField[]; searchFieldValues?: Record; - 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} /> form} metadataSearchFields={metadataSearchFields} searchFieldValues={searchFieldValues} diff --git a/src/frontend/src/components/settings/customFields/OidcAdminHint.tsx b/src/frontend/src/components/settings/customFields/OidcAdminHint.tsx index 2d33abcf..5871dca9 100644 --- a/src/frontend/src/components/settings/customFields/OidcAdminHint.tsx +++ b/src/frontend/src/components/settings/customFields/OidcAdminHint.tsx @@ -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(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 (
diff --git a/src/frontend/src/components/shared/DynamicDropdown.tsx b/src/frontend/src/components/shared/DynamicDropdown.tsx index 914b7553..13a0b6e9 100644 --- a/src/frontend/src/components/shared/DynamicDropdown.tsx +++ b/src/frontend/src/components/shared/DynamicDropdown.tsx @@ -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([]); 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 ( diff --git a/src/frontend/src/components/shared/SearchFieldRenderer.tsx b/src/frontend/src/components/shared/SearchFieldRenderer.tsx index aa2c4e2d..54432cdb 100644 --- a/src/frontend/src/components/shared/SearchFieldRenderer.tsx +++ b/src/frontend/src/components/shared/SearchFieldRenderer.tsx @@ -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 onChange(v)} - placeholder={field.placeholder || 'Select an option'} - allLabel="All" + onChange={(v, label) => onChange(v, label)} + placeholder={field.placeholder || 'Select...'} /> ); diff --git a/src/frontend/src/hooks/useSearch.ts b/src/frontend/src/hooks/useSearch.ts index 90e29a71..dc3acaf4 100644 --- a/src/frontend/src/hooks/useSearch.ts +++ b/src/frontend/src/hooks/useSearch.ts @@ -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; // 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({}); + const [searchFieldLabels, setSearchFieldLabels] = useState>({}); // 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); - - // 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) => { 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,