From 9169c0ff721e619921c8e5dc4b706aeb086e2eac Mon Sep 17 00:00:00 2001 From: spin-drift Date: Sat, 13 Jun 2026 19:24:07 -0400 Subject: [PATCH 1/3] Add universal mode content_type=combined --- docs/url-search-parameters.md | 9 +++++++- src/frontend/src/App.tsx | 3 +++ .../components/UrlSearchBootstrapMount.tsx | 14 ++++++++++++ src/frontend/src/hooks/useUrlSearch.ts | 2 +- .../src/tests/parseUrlSearchParams.test.ts | 18 +++++++++++++++ .../src/utils/parseUrlSearchParams.ts | 22 +++++++++++++------ 6 files changed, 59 insertions(+), 9 deletions(-) diff --git a/docs/url-search-parameters.md b/docs/url-search-parameters.md index 131b4386..53d332d6 100644 --- a/docs/url-search-parameters.md +++ b/docs/url-search-parameters.md @@ -19,7 +19,7 @@ http://your-server:8084/?q=harry+potter | `lang` | Filter by language (ISO 639-1 code) | `/?lang=en` | | `format` | Filter by file format | `/?format=epub` | | `content` | Filter by content type | `/?content=fiction` | -| `content_type` | Select media type (`ebook` or `audiobook`) in Universal mode only | `/?q=dune&content_type=audiobook` | +| `content_type` | Select media type (`ebook`, `audiobook`, or `combined`) in Universal mode only | `/?q=dune&content_type=audiobook` | | `sort` | Sort order for results | `/?sort=newest` | ## Multiple Values @@ -63,6 +63,11 @@ Some parameters support multiple values by repeating the parameter: /?q=dune&content_type=audiobook ``` +**Universal search forcing combined (ebook + audiobook):** +``` +/?q=dune&content_type=combined +``` + ## Search Mode Behavior ### Direct Mode @@ -74,6 +79,8 @@ When Search Mode is set to Direct, all parameters are used to filter results fro `q`, `sort`, and `content_type` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities. +`content_type=combined` forces combined mode (search ebook and audiobook providers together), overriding the last-used preference. It is silently ignored if combined mode is unavailable (e.g. the combined selector is disabled in settings, or either content type is blocked by request policy). + ## Notes - URL parameters are read once on page load diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index d6d9d69d..0d7d0d0b 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -2774,10 +2774,13 @@ function App() { parsedParams={parsedParams} config={config} contentType={contentType} + combinedMode={combinedMode} + combinedModeAllowed={combinedModeAllowed} advancedFilters={advancedFilters} resolvedMetadataDefaultSort={resolvedMetadataDefaultSort} resolvedMetadataSortOptions={resolvedMetadataSortOptions} setContentType={setContentType} + setCombinedMode={setCombinedMode} setSearchInput={setSearchInput} setAdvancedFilters={setAdvancedFilters} setShowAdvanced={setShowAdvanced} diff --git a/src/frontend/src/components/UrlSearchBootstrapMount.tsx b/src/frontend/src/components/UrlSearchBootstrapMount.tsx index 47f9cea6..89591e38 100644 --- a/src/frontend/src/components/UrlSearchBootstrapMount.tsx +++ b/src/frontend/src/components/UrlSearchBootstrapMount.tsx @@ -12,10 +12,13 @@ interface UrlSearchBootstrapMountProps { parsedParams: ParsedUrlSearch; config: AppConfig; contentType: ContentType; + combinedMode: boolean; + combinedModeAllowed: boolean; advancedFilters: AdvancedFilterState; resolvedMetadataDefaultSort: string; resolvedMetadataSortOptions: SortOption[]; setContentType: (value: ContentType) => void; + setCombinedMode: (value: boolean) => void; setSearchInput: (value: string) => void; setAdvancedFilters: Dispatch>; setShowAdvanced: (value: boolean) => void; @@ -32,10 +35,13 @@ export const UrlSearchBootstrapMount = ({ parsedParams, config, contentType, + combinedMode, + combinedModeAllowed, advancedFilters, resolvedMetadataDefaultSort, resolvedMetadataSortOptions, setContentType, + setCombinedMode, setSearchInput, setAdvancedFilters, setShowAdvanced, @@ -49,11 +55,19 @@ export const UrlSearchBootstrapMount = ({ const parsedSearchMode = config.search_mode || 'universal'; const urlContentTypeOverride = parsedSearchMode === 'universal' ? parsedParams.contentType : undefined; + const urlForcesCombined = + parsedSearchMode === 'universal' && parsedParams.combinedMode === true && combinedModeAllowed; if (urlContentTypeOverride && urlContentTypeOverride !== contentType) { setContentType(urlContentTypeOverride); } + if (urlForcesCombined && !combinedMode) { + setCombinedMode(true); + } else if (urlContentTypeOverride && combinedMode) { + setCombinedMode(false); + } + if (!parsedParams.hasSearchParams) { return; } diff --git a/src/frontend/src/hooks/useUrlSearch.ts b/src/frontend/src/hooks/useUrlSearch.ts index ca9cd248..60c106ab 100644 --- a/src/frontend/src/hooks/useUrlSearch.ts +++ b/src/frontend/src/hooks/useUrlSearch.ts @@ -41,7 +41,7 @@ export function useUrlSearch({ enabled }: UseUrlSearchOptions): UseUrlSearchRetu } const parsed = parseUrlSearchParams(searchParams); - return parsed.hasSearchParams || parsed.contentType ? parsed : null; + return parsed.hasSearchParams || parsed.contentType || parsed.combinedMode ? parsed : null; }, [enabled, searchParams]); return { diff --git a/src/frontend/src/tests/parseUrlSearchParams.test.ts b/src/frontend/src/tests/parseUrlSearchParams.test.ts index 497bb619..8f74ab79 100644 --- a/src/frontend/src/tests/parseUrlSearchParams.test.ts +++ b/src/frontend/src/tests/parseUrlSearchParams.test.ts @@ -42,4 +42,22 @@ describe('parseUrlSearchParams', () => { expect(parsed.hasSearchParams).toBe(false); expect(parsed.contentType).toBe('ebook'); }); + + it('parses content_type=combined as a combined-mode override', () => { + const parsed = parseUrlSearchParams(new URLSearchParams('q=dune&content_type=combined')); + + expect(parsed.searchInput).toBe('dune'); + expect(parsed.hasSearchParams).toBe(true); + expect(parsed.contentType).toBe(undefined); + expect(parsed.combinedMode).toBe(true); + }); + + it('keeps combined-only links from auto-triggering a blank search', () => { + const parsed = parseUrlSearchParams(new URLSearchParams('content_type=combined')); + + expect(parsed.searchInput).toBe(''); + expect(parsed.hasSearchParams).toBe(false); + expect(parsed.contentType).toBe(undefined); + expect(parsed.combinedMode).toBe(true); + }); }); diff --git a/src/frontend/src/utils/parseUrlSearchParams.ts b/src/frontend/src/utils/parseUrlSearchParams.ts index 23d11195..a37c3f97 100644 --- a/src/frontend/src/utils/parseUrlSearchParams.ts +++ b/src/frontend/src/utils/parseUrlSearchParams.ts @@ -7,18 +7,24 @@ export interface ParsedUrlSearch { searchInput: string; advancedFilters: Partial; contentType?: ContentType; + combinedMode?: boolean; hasSearchParams: boolean; } -const parseContentType = (value: string | null): ContentType | undefined => { +const parseContentTypeParam = ( + value: string | null, +): { contentType?: ContentType; combinedMode?: true } => { if (!value) { - return undefined; + return {}; } const normalized = value.trim().toLowerCase(); if (normalized === 'ebook' || normalized === 'audiobook') { - return normalized; + return { contentType: normalized }; } - return undefined; + if (normalized === 'combined') { + return { combinedMode: true }; + } + return {}; }; /** @@ -26,21 +32,23 @@ const parseContentType = (value: string | null): ContentType | undefined => { * * Supports both Direct Download and Universal mode parameters. * In Universal mode, query/sort are used for search text, and content_type - * is used to select ebook vs audiobook. + * selects ebook, audiobook, or combined (search both at once). * * @example * // Direct mode: /?q=harry+potter&author=rowling&format=epub&lang=en * // Universal mode: /?q=dune&sort=popularity + * // Universal combined: /?q=dune&content_type=combined */ export function parseUrlSearchParams(searchParams: URLSearchParams): ParsedUrlSearch { - const parsedContentType = parseContentType( + const contentTypeParam = parseContentTypeParam( searchParams.get('content_type') || searchParams.get('contentType'), ); const result: ParsedUrlSearch = { searchInput: '', advancedFilters: {}, - contentType: parsedContentType, + contentType: contentTypeParam.contentType, + combinedMode: contentTypeParam.combinedMode, hasSearchParams: false, }; From c8af794ffb94ce327d9e10e4977f92dc6d6abddd Mon Sep 17 00:00:00 2001 From: spin-drift Date: Sat, 13 Jun 2026 20:04:28 -0400 Subject: [PATCH 2/3] Add setting to force combined searches --- docs/environment-variables.md | 10 ++++++++++ shelfmark/config/settings.py | 8 ++++++++ shelfmark/config/users_settings.py | 6 ++++++ shelfmark/main.py | 3 +++ src/frontend/src/App.tsx | 8 ++++++-- src/frontend/src/components/Header.tsx | 3 +++ src/frontend/src/components/SearchBar.tsx | 20 ++++++++++++++----- src/frontend/src/components/SearchSection.tsx | 3 +++ src/frontend/src/types/index.ts | 1 + tests/core/test_admin_users_api.py | 1 + 10 files changed, 56 insertions(+), 7 deletions(-) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 5b61ad0e..a3390382 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -223,6 +223,7 @@ Default language filter for searches. | `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` | +| `FORCE_COMBINED_SEARCH` | Force combined search whenever it's available. Locks the combined toggle on. | boolean | `false` | | `METADATA_PROVIDER` | Choose which metadata provider to use for book searches. | string (choice) | `openlibrary` | | `METADATA_PROVIDER_AUDIOBOOK` | Metadata provider for audiobook searches. Uses the book provider if not set. | string (choice) | _empty string_ | | `METADATA_PROVIDER_COMBINED` | Metadata provider for combined mode searches. Uses the book provider if not set. | string (choice) | _empty string_ | @@ -270,6 +271,15 @@ Show the option to search for and download both a book and audiobook together. - **Type:** boolean - **Default:** `true` +#### `FORCE_COMBINED_SEARCH` + +**Always Use Combined Search** + +Force combined search whenever it's available. Locks the combined toggle on; the lock icon replaces the chain in the search bar. + +- **Type:** boolean +- **Default:** `false` + #### `METADATA_PROVIDER` **Book Metadata Provider** diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 18da1631..9340293b 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -453,6 +453,14 @@ def search_mode_settings() -> list[SettingsField]: show_when={"field": "SEARCH_MODE", "value": "universal"}, user_overridable=True, ), + CheckboxField( + key="FORCE_COMBINED_SEARCH", + label="Always Use Combined Search", + description="Force combined search whenever it's available. Locks the combined toggle on.", + default=False, + show_when={"field": "SEARCH_MODE", "value": "universal"}, + user_overridable=True, + ), HeadingField( key="universal_mode_heading", title="Universal Mode Settings", diff --git a/shelfmark/config/users_settings.py b/shelfmark/config/users_settings.py index dab1a5ec..f5b84604 100644 --- a/shelfmark/config/users_settings.py +++ b/shelfmark/config/users_settings.py @@ -82,6 +82,7 @@ "DEFAULT_RELEASE_SOURCE", "DEFAULT_RELEASE_SOURCE_AUDIOBOOK", "SHOW_COMBINED_SELECTOR", + "FORCE_COMBINED_SEARCH", *_SEARCH_PREFERENCE_PROVIDER_KEYS, } @@ -223,6 +224,11 @@ def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | N return value, None return bool(value), None + if key == "FORCE_COMBINED_SEARCH": + if isinstance(value, bool): + return value, None + return bool(value), None + return value, None diff --git a/shelfmark/main.py b/shelfmark/main.py index 19557d2b..46fdbff9 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -1163,6 +1163,9 @@ def api_config() -> Response | tuple[Response, int]: "show_combined_selector": app_config.get( "SHOW_COMBINED_SELECTOR", True, user_id=db_user_id ), + "force_combined_search": app_config.get( + "FORCE_COMBINED_SEARCH", False, user_id=db_user_id + ), "books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"), "auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True), "hardcover_auto_remove_on_download": app_config.get( diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 0d7d0d0b..367d440f 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -766,7 +766,8 @@ function App() { (cfg.show_combined_selector ?? true) && getDefaultMode('ebook') !== 'blocked' && getDefaultMode('audiobook') !== 'blocked'; - const nextEffectiveCombinedMode = combinedMode && nextCombinedModeAllowed; + const nextEffectiveCombinedMode = + nextCombinedModeAllowed && (combinedMode || cfg.force_combined_search); const activeConfiguredProvider = nextEffectiveCombinedMode && metadataProviderState.configured_provider_combined ? metadataProviderState.configured_provider_combined @@ -864,7 +865,8 @@ function App() { const audiobookMode = getDefaultMode('audiobook'); return ebookMode !== 'blocked' && audiobookMode !== 'blocked'; }, [effectiveSearchMode, config?.show_combined_selector, getDefaultMode]); - const effectiveCombinedMode = combinedMode && combinedModeAllowed; + const combinedModeLocked = combinedModeAllowed && config?.force_combined_search === true; + const effectiveCombinedMode = combinedModeAllowed && (combinedMode || combinedModeLocked); const effectiveCombinedState = effectiveCombinedMode ? combinedState : null; const defaultMetadataProviderForContentType = @@ -2418,6 +2420,7 @@ function App() { onContentTypeChange={setContentType} allowedContentTypes={allowedContentTypes} combinedMode={effectiveCombinedMode} + combinedModeLocked={combinedModeLocked} onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined} queryTargets={queryTargets} activeQueryTarget={effectiveActiveQueryTarget} @@ -2499,6 +2502,7 @@ function App() { onContentTypeChange={setContentType} allowedContentTypes={allowedContentTypes} combinedMode={effectiveCombinedMode} + combinedModeLocked={combinedModeLocked} onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined} activeQueryField={activeQueryField} searchMode={effectiveSearchMode} diff --git a/src/frontend/src/components/Header.tsx b/src/frontend/src/components/Header.tsx index 5f18c418..d4e3868d 100644 --- a/src/frontend/src/components/Header.tsx +++ b/src/frontend/src/components/Header.tsx @@ -62,6 +62,7 @@ interface HeaderProps { onContentTypeChange?: (type: ContentType) => void; allowedContentTypes?: ContentType[]; combinedMode?: boolean; + combinedModeLocked?: boolean; onCombinedModeChange?: (enabled: boolean) => void; queryTargets?: QueryTargetOption[]; activeQueryTarget?: string; @@ -127,6 +128,7 @@ export const Header = forwardRef( onContentTypeChange, allowedContentTypes, combinedMode, + combinedModeLocked, onCombinedModeChange, queryTargets = EMPTY_QUERY_TARGETS, activeQueryTarget = 'general', @@ -721,6 +723,7 @@ export const Header = forwardRef( onContentTypeChange={onContentTypeChange} allowedContentTypes={allowedContentTypes} combinedMode={combinedMode} + combinedModeLocked={combinedModeLocked} onCombinedModeChange={onCombinedModeChange} queryTargets={queryTargets} activeQueryTarget={activeQueryTarget} diff --git a/src/frontend/src/components/SearchBar.tsx b/src/frontend/src/components/SearchBar.tsx index 40a3c096..bfd74ed1 100644 --- a/src/frontend/src/components/SearchBar.tsx +++ b/src/frontend/src/components/SearchBar.tsx @@ -34,6 +34,7 @@ interface SearchBarProps { onContentTypeChange?: (type: ContentType) => void; allowedContentTypes?: ContentType[]; combinedMode?: boolean; + combinedModeLocked?: boolean; onCombinedModeChange?: (enabled: boolean) => void; queryTargets?: QueryTargetOption[]; activeQueryTarget?: string; @@ -181,6 +182,7 @@ export const SearchBar = forwardRef( onContentTypeChange, allowedContentTypes, combinedMode = false, + combinedModeLocked = false, onCombinedModeChange, queryTargets = EMPTY_QUERY_TARGETS, activeQueryTarget = 'general', @@ -798,11 +800,19 @@ export const SearchBar = forwardRef( stroke="currentColor" aria-hidden="true" > - + {combinedModeLocked ? ( + + ) : ( + + )} diff --git a/src/frontend/src/components/SearchSection.tsx b/src/frontend/src/components/SearchSection.tsx index f652e4af..0726a430 100644 --- a/src/frontend/src/components/SearchSection.tsx +++ b/src/frontend/src/components/SearchSection.tsx @@ -32,6 +32,7 @@ interface SearchSectionProps { onContentTypeChange?: (type: ContentType) => void; allowedContentTypes?: ContentType[]; combinedMode?: boolean; + combinedModeLocked?: boolean; onCombinedModeChange?: (enabled: boolean) => void; activeQueryField?: MetadataSearchField | null; searchMode: SearchMode; @@ -64,6 +65,7 @@ export const SearchSection = ({ onContentTypeChange, allowedContentTypes, combinedMode, + combinedModeLocked, onCombinedModeChange, activeQueryField, searchMode, @@ -105,6 +107,7 @@ export const SearchSection = ({ onContentTypeChange={onContentTypeChange} allowedContentTypes={allowedContentTypes} combinedMode={combinedMode} + combinedModeLocked={combinedModeLocked} onCombinedModeChange={onCombinedModeChange} queryTargets={queryTargets} activeQueryTarget={activeQueryTarget} diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 24c07b92..35b2f453 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -280,6 +280,7 @@ export interface AppConfig { default_release_source_audiobook?: string; // Default tab in ReleaseModal for audiobooks show_release_source_links: boolean; show_combined_selector: boolean; + force_combined_search: boolean; books_output_mode: BooksOutputMode; auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued hardcover_auto_remove_on_download: boolean; // Auto-remove from active Hardcover list on download diff --git a/tests/core/test_admin_users_api.py b/tests/core/test_admin_users_api.py index 3d2c39a7..cfc157be 100644 --- a/tests/core/test_admin_users_api.py +++ b/tests/core/test_admin_users_api.py @@ -1267,6 +1267,7 @@ def test_returns_curated_fields_and_effective_values(self, admin_client, user_db assert data["keys"] == [ "SEARCH_MODE", "SHOW_COMBINED_SELECTOR", + "FORCE_COMBINED_SEARCH", "METADATA_PROVIDER", "METADATA_PROVIDER_AUDIOBOOK", "METADATA_PROVIDER_COMBINED", From 0481f09fe45f194c78127f70b7e4427ae5386e91 Mon Sep 17 00:00:00 2001 From: spin-drift Date: Sat, 13 Jun 2026 20:15:32 -0400 Subject: [PATCH 3/3] Remove unnecessary copy --- docs/environment-variables.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index a3390382..d3206040 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -275,7 +275,7 @@ Show the option to search for and download both a book and audiobook together. **Always Use Combined Search** -Force combined search whenever it's available. Locks the combined toggle on; the lock icon replaces the chain in the search bar. +Force combined search whenever it's available. Locks the combined toggle on. - **Type:** boolean - **Default:** `false`