Add content_type=combined URL parameter and FORCE_COMBINED_SEARCH user setting (#1058)

Adds support for `content_type=combined` in URL search parameters,
letting users force combined-mode searches via a bookmarkable link
rather than relying on the last-used preference from localStorage.

The override is applied only in Universal mode and only when combined
mode is actually available (universal enabled, `show_combined_selector`
on, neither content type blocked by policy). Otherwise, it's silently
ignored, consistent with how `content_type=ebook`/`audiobook` already
behave outside Universal.

Existing `content_type=ebook`/`audiobook` URLs now also force combined
mode off, so the URL is authoritative regardless of prior preference.

Also adds a per-user `FORCE_COMBINED_SEARCH` setting that locks combined
mode on whenever it's available.

URL `content_type=ebook`/`audiobook` overrides are also superseded by
force-combined for the same reason: the search bar wouldn't let users
switch back, so honoring the URL param would leave them in a state they
couldn't escape from.
This commit is contained in:
spindrift
2026-06-14 00:09:30 -04:00
committed by GitHub
parent b2131fa940
commit 0633cfde87
15 changed files with 115 additions and 16 deletions
+10
View File
@@ -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.
- **Type:** boolean
- **Default:** `false`
#### `METADATA_PROVIDER`
**Book Metadata Provider**
+8 -1
View File
@@ -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
+8
View File
@@ -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",
+6
View File
@@ -82,6 +82,7 @@ _SEARCH_PREFERENCE_VALIDATABLE_KEYS = {
"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
+3
View File
@@ -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(
+9 -2
View File
@@ -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}
@@ -2774,10 +2778,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}
+3
View File
@@ -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<HeaderHandle, HeaderProps>(
onContentTypeChange,
allowedContentTypes,
combinedMode,
combinedModeLocked,
onCombinedModeChange,
queryTargets = EMPTY_QUERY_TARGETS,
activeQueryTarget = 'general',
@@ -721,6 +723,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
onContentTypeChange={onContentTypeChange}
allowedContentTypes={allowedContentTypes}
combinedMode={combinedMode}
combinedModeLocked={combinedModeLocked}
onCombinedModeChange={onCombinedModeChange}
queryTargets={queryTargets}
activeQueryTarget={activeQueryTarget}
+15 -5
View File
@@ -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<SearchBarHandle, SearchBarProps>(
onContentTypeChange,
allowedContentTypes,
combinedMode = false,
combinedModeLocked = false,
onCombinedModeChange,
queryTargets = EMPTY_QUERY_TARGETS,
activeQueryTarget = 'general',
@@ -798,11 +800,19 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
/>
{combinedModeLocked ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
/>
)}
</svg>
</div>
</div>
@@ -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}
@@ -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<SetStateAction<AdvancedFilterState>>;
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;
}
+1 -1
View File
@@ -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 {
@@ -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);
});
});
+1
View File
@@ -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
+15 -7
View File
@@ -7,18 +7,24 @@ export interface ParsedUrlSearch {
searchInput: string;
advancedFilters: Partial<AdvancedFilterState>;
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,
};
+1
View File
@@ -1267,6 +1267,7 @@ class TestAdminSearchPreferences:
assert data["keys"] == [
"SEARCH_MODE",
"SHOW_COMBINED_SELECTOR",
"FORCE_COMBINED_SEARCH",
"METADATA_PROVIDER",
"METADATA_PROVIDER_AUDIOBOOK",
"METADATA_PROVIDER_COMBINED",