Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_ |
Expand Down Expand Up @@ -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**
Expand Down
9 changes: 8 additions & 1 deletion docs/url-search-parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions shelfmark/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions shelfmark/config/users_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"DEFAULT_RELEASE_SOURCE",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
"SHOW_COMBINED_SELECTOR",
"FORCE_COMBINED_SEARCH",
*_SEARCH_PREFERENCE_PROVIDER_KEYS,
}

Expand Down Expand Up @@ -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


Expand Down
3 changes: 3 additions & 0 deletions shelfmark/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 9 additions & 2 deletions src/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -2418,6 +2420,7 @@ function App() {
onContentTypeChange={setContentType}
allowedContentTypes={allowedContentTypes}
combinedMode={effectiveCombinedMode}
combinedModeLocked={combinedModeLocked}
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
queryTargets={queryTargets}
activeQueryTarget={effectiveActiveQueryTarget}
Expand Down Expand Up @@ -2499,6 +2502,7 @@ function App() {
onContentTypeChange={setContentType}
allowedContentTypes={allowedContentTypes}
combinedMode={effectiveCombinedMode}
combinedModeLocked={combinedModeLocked}
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
activeQueryField={activeQueryField}
searchMode={effectiveSearchMode}
Expand Down Expand Up @@ -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}
Expand Down
3 changes: 3 additions & 0 deletions src/frontend/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ interface HeaderProps {
onContentTypeChange?: (type: ContentType) => void;
allowedContentTypes?: ContentType[];
combinedMode?: boolean;
combinedModeLocked?: boolean;
onCombinedModeChange?: (enabled: boolean) => void;
queryTargets?: QueryTargetOption[];
activeQueryTarget?: string;
Expand Down Expand Up @@ -127,6 +128,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
onContentTypeChange,
allowedContentTypes,
combinedMode,
combinedModeLocked,
onCombinedModeChange,
queryTargets = EMPTY_QUERY_TARGETS,
activeQueryTarget = 'general',
Expand Down Expand Up @@ -721,6 +723,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
onContentTypeChange={onContentTypeChange}
allowedContentTypes={allowedContentTypes}
combinedMode={combinedMode}
combinedModeLocked={combinedModeLocked}
onCombinedModeChange={onCombinedModeChange}
queryTargets={queryTargets}
activeQueryTarget={activeQueryTarget}
Expand Down
20 changes: 15 additions & 5 deletions src/frontend/src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface SearchBarProps {
onContentTypeChange?: (type: ContentType) => void;
allowedContentTypes?: ContentType[];
combinedMode?: boolean;
combinedModeLocked?: boolean;
onCombinedModeChange?: (enabled: boolean) => void;
queryTargets?: QueryTargetOption[];
activeQueryTarget?: string;
Expand Down Expand Up @@ -181,6 +182,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
onContentTypeChange,
allowedContentTypes,
combinedMode = false,
combinedModeLocked = false,
onCombinedModeChange,
queryTargets = EMPTY_QUERY_TARGETS,
activeQueryTarget = 'general',
Expand Down Expand Up @@ -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>
Expand Down
3 changes: 3 additions & 0 deletions src/frontend/src/components/SearchSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -64,6 +65,7 @@ export const SearchSection = ({
onContentTypeChange,
allowedContentTypes,
combinedMode,
combinedModeLocked,
onCombinedModeChange,
activeQueryField,
searchMode,
Expand Down Expand Up @@ -105,6 +107,7 @@ export const SearchSection = ({
onContentTypeChange={onContentTypeChange}
allowedContentTypes={allowedContentTypes}
combinedMode={combinedMode}
combinedModeLocked={combinedModeLocked}
onCombinedModeChange={onCombinedModeChange}
queryTargets={queryTargets}
activeQueryTarget={activeQueryTarget}
Expand Down
14 changes: 14 additions & 0 deletions src/frontend/src/components/UrlSearchBootstrapMount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,10 +35,13 @@ export const UrlSearchBootstrapMount = ({
parsedParams,
config,
contentType,
combinedMode,
combinedModeAllowed,
advancedFilters,
resolvedMetadataDefaultSort,
resolvedMetadataSortOptions,
setContentType,
setCombinedMode,
setSearchInput,
setAdvancedFilters,
setShowAdvanced,
Expand All @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/src/hooks/useUrlSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions src/frontend/src/tests/parseUrlSearchParams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 change: 1 addition & 0 deletions src/frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 15 additions & 7 deletions src/frontend/src/utils/parseUrlSearchParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,40 +7,48 @@ 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 {};
};

/**
* Parse URL search parameters into search state.
*
* 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,
};

Expand Down
1 change: 1 addition & 0 deletions tests/core/test_admin_users_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down