feat(search): add a configurable default content type (#1371)

Closes #1018.

Worth correcting the issue first: the search tab is not hardcoded.
`useContentTypePreferences` has persisted the user's choice to
localStorage since #564, so a browser that has picked a tab already
keeps it. What is missing is the other half the issue asks for, a
default for a browser that has stored nothing, and a per-user override.

`DEFAULT_CONTENT_TYPE` is a user overridable select next to
`BOOK_LANGUAGE`, so it follows the same path: global value in Settings,
per-user value in Search Preferences, resolved with `user_id` in
`/api/config`. The frontend uses it only when this browser has no stored
choice, which is captured before the existing effect writes one, so
nothing changes for anyone who has already picked a tab.

The resolution is a pure function in `utils/contentTypePreference.ts`
rather than logic inside the hook, since vitest here has no jsdom and
the existing tests cover resolvers like `resolveDefaultLanguageCodes`
the same way.

One small move in `App.tsx`: the `config` state was declared below the
hook that now reads it, so it moved above it.

## Verification

- `tests/core/test_config_api.py`: the payload carries
`default_content_type` and reads it with the user's id.
- `tests/core/test_admin_users_api.py`: the key appears in the per-user
search preferences list.
- `src/frontend/src/tests/contentTypePreference.test.ts`: a stored tab
wins, combined mode survives, the server default applies when nothing is
stored, and an unrecognised value falls back to ebook.
- Python suite (3163) and frontend suite (201) green, plus ruff, ruff
format, basedpyright, vulture, tsc, oxlint, oxfmt and the production
build.
This commit is contained in:
splitsec2
2026-09-25 18:14:42 -04:00
committed by GitHub
parent ce1092db7f
commit fe99d4bb5b
10 changed files with 182 additions and 23 deletions
+11
View File
@@ -314,6 +314,7 @@ Audiobook formats to include in search results. ZIP/RAR archives are extracted a
|----------|-------------|------|---------|
| `SEARCH_MODE` | How you want to search for and download books. | string (choice) | `universal` |
| `BOOK_LANGUAGE` | Default language filter for searches. Users can override this for their own account. | string (comma-separated) | `en` |
| `DEFAULT_CONTENT_TYPE` | Which tab the search page opens on. Users can override this for their own account, and a browser that has already picked a tab keeps its choice. | string (choice) | `ebook` |
| `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` |
@@ -346,6 +347,16 @@ Default language filter for searches. Users can override this for their own acco
- **Type:** string (comma-separated)
- **Default:** `en`
#### `DEFAULT_CONTENT_TYPE`
**Default Content Type**
Which tab the search page opens on. Users can override this for their own account, and a browser that has already picked a tab keeps its choice.
- **Type:** string (choice)
- **Default:** `ebook`
- **Options:** `ebook` (Ebook), `audiobook` (Audiobook)
#### `AA_DEFAULT_SORT`
**Default Sort Order**
+14
View File
@@ -479,6 +479,20 @@ def search_mode_settings() -> list[SettingsField]:
default=["en"],
user_overridable=True,
),
SelectField(
key="DEFAULT_CONTENT_TYPE",
label="Default Content Type",
description=(
"Which tab the search page opens on. Users can override this for their "
"own account, and a browser that has already picked a tab keeps its choice."
),
options=[
{"value": "ebook", "label": "Ebook"},
{"value": "audiobook", "label": "Audiobook"},
],
default="ebook",
user_overridable=True,
),
SelectField(
key="AA_DEFAULT_SORT",
label="Default Sort Order",
+3
View File
@@ -1248,6 +1248,9 @@ def api_config() -> Response | tuple[Response, int]:
"release_version": RELEASE_VERSION,
"book_languages": _SUPPORTED_BOOK_LANGUAGE,
"default_language": app_config.get("BOOK_LANGUAGE", ["en"], user_id=db_user_id),
"default_content_type": app_config.get(
"DEFAULT_CONTENT_TYPE", "ebook", user_id=db_user_id
),
"supported_formats": app_config.SUPPORTED_FORMATS,
"supported_audiobook_formats": app_config.SUPPORTED_AUDIOBOOK_FORMATS,
"search_mode": search_mode,
+6 -3
View File
@@ -308,9 +308,13 @@ function App() {
showToast,
});
// Declared here because the content type default below reads from it
const [config, setConfig] = useState<AppConfig | null>(null);
// Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it
const { contentType, setContentType, combinedMode, setCombinedMode } =
useContentTypePreferences();
const { contentType, setContentType, combinedMode, setCombinedMode } = useContentTypePreferences(
config?.default_content_type,
);
const {
policy: requestPolicy,
@@ -612,7 +616,6 @@ function App() {
// Combined mode state (ebook + audiobook in one transaction)
const [combinedState, setCombinedState] = useState<CombinedSelectionState | null>(null);
const [config, setConfig] = useState<AppConfig | null>(null);
const [metadataProviders, setMetadataProviders] = useState<MetadataProviderSummary[]>([]);
const [configuredMetadataProvider, setConfiguredMetadataProvider] = useState<string | null>(null);
const [configuredAudiobookMetadataProvider, setConfiguredAudiobookMetadataProvider] = useState<
@@ -2,31 +2,25 @@ import { useCallback, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import type { ContentType } from '../../types';
import {
CONTENT_TYPE_STORAGE_KEY,
resolveContentTypePreference,
type ContentTypePreference,
} from '../../utils/contentTypePreference';
import { useDependencyEffect } from '../useMountEffect';
const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type';
interface ContentTypePreference {
contentType: ContentType;
combinedMode: boolean;
}
const readInitialPreference = (): ContentTypePreference => {
const readStoredPreference = (): string | null => {
try {
const saved = localStorage.getItem(CONTENT_TYPE_STORAGE_KEY);
if (saved === 'combined') {
return { contentType: 'ebook', combinedMode: true };
}
if (saved === 'ebook' || saved === 'audiobook') {
return { contentType: saved, combinedMode: false };
}
return localStorage.getItem(CONTENT_TYPE_STORAGE_KEY);
} catch {
// localStorage may be unavailable in private browsing
return null;
}
return { contentType: 'ebook', combinedMode: false };
};
export const useContentTypePreferences = (): {
export const useContentTypePreferences = (
serverDefault?: ContentType | null,
): {
contentType: ContentType;
setContentType: Dispatch<SetStateAction<ContentType>>;
combinedMode: boolean;
@@ -34,13 +28,16 @@ export const useContentTypePreferences = (): {
} => {
// Both values live in one state object so each setter can derive the other
// from a pure updater instead of mirroring it into a ref during render.
const [preference, setPreference] = useState<ContentTypePreference>(readInitialPreference);
const { contentType, combinedMode } = preference;
const [preference, setPreference] = useState<ContentTypePreference>(() =>
resolveContentTypePreference(readStoredPreference(), null),
);
const { contentType, combinedMode, source } = preference;
const setContentType: Dispatch<SetStateAction<ContentType>> = useCallback((value) => {
setPreference((current) => ({
...current,
contentType: typeof value === 'function' ? value(current.contentType) : value,
source: 'chosen',
}));
}, []);
@@ -48,16 +45,34 @@ export const useContentTypePreferences = (): {
setPreference((current) => ({
...current,
combinedMode: typeof value === 'function' ? value(current.combinedMode) : value,
source: 'chosen',
}));
}, []);
// The config arrives after the first render, so the configured default is applied here.
// It only lands while nothing has chosen a tab, which leaves a deep-linked content type
// and a stored choice both untouched, and it does not count as a choice itself.
useDependencyEffect(() => {
if (!serverDefault) {
return;
}
setPreference((current) =>
current.source === 'unset' ? resolveContentTypePreference(null, serverDefault) : current,
);
}, [serverDefault]);
// Persist only what the user actually chose. Writing on every load would store a default
// nobody picked, which is what stopped the configured default from ever being seen.
useDependencyEffect(() => {
if (source !== 'chosen') {
return;
}
try {
localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, combinedMode ? 'combined' : contentType);
} catch {
// localStorage may be unavailable in private browsing
}
}, [contentType, combinedMode]);
}, [contentType, combinedMode, source]);
return {
contentType,
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { resolveContentTypePreference } from '../utils/contentTypePreference';
describe('resolveContentTypePreference', () => {
it('keeps the tab this browser chose, over any configured default', () => {
expect(resolveContentTypePreference('audiobook', 'ebook')).toEqual({
contentType: 'audiobook',
combinedMode: false,
source: 'chosen',
});
expect(resolveContentTypePreference('ebook', 'audiobook')).toEqual({
contentType: 'ebook',
combinedMode: false,
source: 'chosen',
});
});
it('keeps combined mode, which is stored as its own value', () => {
expect(resolveContentTypePreference('combined', 'audiobook')).toEqual({
contentType: 'ebook',
combinedMode: true,
source: 'chosen',
});
});
it('uses the configured default when this browser has chosen nothing', () => {
expect(resolveContentTypePreference(null, 'audiobook')).toEqual({
contentType: 'audiobook',
combinedMode: false,
source: 'unset',
});
});
it('leaves a configured default open to being overridden', () => {
// 'unset' is what lets a deep-linked content type and a later setting change win.
expect(resolveContentTypePreference(null, 'audiobook').source).toBe('unset');
expect(resolveContentTypePreference('audiobook', null).source).toBe('chosen');
});
it('falls back to ebook with neither a stored choice nor a default', () => {
expect(resolveContentTypePreference(null, null)).toEqual({
contentType: 'ebook',
combinedMode: false,
source: 'unset',
});
expect(resolveContentTypePreference(null, undefined).contentType).toBe('ebook');
});
it('ignores a stored or configured value it does not recognise', () => {
expect(resolveContentTypePreference('comic', 'audiobook')).toEqual({
contentType: 'audiobook',
combinedMode: false,
source: 'unset',
});
expect(resolveContentTypePreference(null, 'comic')).toEqual({
contentType: 'ebook',
combinedMode: false,
source: 'unset',
});
});
});
+1
View File
@@ -288,6 +288,7 @@ export interface AppConfig {
release_version: string;
book_languages: Language[];
default_language: string[];
default_content_type: ContentType;
supported_formats: string[];
supported_audiobook_formats: string[]; // Audiobook formats (m4b, mp3)
search_mode: SearchMode;
@@ -0,0 +1,46 @@
import type { ContentType } from '../types';
/**
* Storage key for the tab this browser last chose.
*
* Bumped from `preferred-content-type`, which was written on every page load rather than
* when the user picked a tab, so its value says nothing about what the user wanted. Reading
* it would let a default that was never chosen outrank the configured one forever.
*/
export const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type.v2';
/** Where the current content type came from, which decides what may still override it. */
export type ContentTypeSource =
/** Nothing has chosen a tab, so a configured default may still apply. */
| 'unset'
/** This browser stored a choice, or something explicit set one. Defaults do not apply. */
| 'chosen';
export interface ContentTypePreference {
contentType: ContentType;
combinedMode: boolean;
source: ContentTypeSource;
}
/**
* Resolve the tab to open on.
*
* A value this browser stored wins, because the user picked it. Otherwise the configured
* default applies, and stays `unset` so it does not get mistaken for a choice: a deep link
* still overrides it, and changing the setting reaches browsers that never picked a tab.
*/
export const resolveContentTypePreference = (
stored: string | null,
serverDefault: string | null | undefined,
): ContentTypePreference => {
if (stored === 'combined') {
return { contentType: 'ebook', combinedMode: true, source: 'chosen' };
}
if (stored === 'ebook' || stored === 'audiobook') {
return { contentType: stored, combinedMode: false, source: 'chosen' };
}
if (serverDefault === 'audiobook' || serverDefault === 'ebook') {
return { contentType: serverDefault, combinedMode: false, source: 'unset' };
}
return { contentType: 'ebook', combinedMode: false, source: 'unset' };
};
+1
View File
@@ -1367,6 +1367,7 @@ class TestAdminSearchPreferences:
assert data["keys"] == [
"SEARCH_MODE",
"BOOK_LANGUAGE",
"DEFAULT_CONTENT_TYPE",
"SHOW_COMBINED_SELECTOR",
"FORCE_COMBINED_SEARCH",
"METADATA_PROVIDER",
+3
View File
@@ -49,6 +49,7 @@ def test_config_endpoint_uses_user_scope_and_runtime_flags(main_module, client):
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK": "audiobookbay",
"DOWNLOAD_TO_BROWSER_CONTENT_TYPES": ["book", "audiobook"],
"BOOK_LANGUAGE": ["de", "en"],
"DEFAULT_CONTENT_TYPE": "audiobook",
"AUTO_OPEN_DOWNLOADS_SIDEBAR": False,
"HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD": True,
"AA_DEFAULT_SORT": "newest",
@@ -77,6 +78,7 @@ def test_config_endpoint_uses_user_scope_and_runtime_flags(main_module, client):
assert data["default_release_source_audiobook"] == "audiobookbay"
assert data["download_to_browser_content_types"] == ["book", "audiobook"]
assert data["default_language"] == ["de", "en"]
assert data["default_content_type"] == "audiobook"
assert data["settings_enabled"] is True
assert data["metadata_default_sort"] == "relevance"
@@ -84,6 +86,7 @@ def test_config_endpoint_uses_user_scope_and_runtime_flags(main_module, client):
assert ("SHOW_COMBINED_SELECTOR", 42) in calls
assert ("DOWNLOAD_TO_BROWSER_CONTENT_TYPES", 42) in calls
assert ("BOOK_LANGUAGE", 42) in calls
assert ("DEFAULT_CONTENT_TYPE", 42) in calls
def test_config_endpoint_falls_back_to_audiobook_metadata_provider(main_module, client):