diff --git a/docs/environment-variables.md b/docs/environment-variables.md index a4c0f395..01e572e5 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -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** diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index b1cf813e..476e9d54 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -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", diff --git a/shelfmark/main.py b/shelfmark/main.py index e1ad0960..e1936cfa 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -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, diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index bd72c271..4433c160 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -308,9 +308,13 @@ function App() { showToast, }); + // Declared here because the content type default below reads from it + const [config, setConfig] = useState(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(null); - const [config, setConfig] = useState(null); const [metadataProviders, setMetadataProviders] = useState([]); const [configuredMetadataProvider, setConfiguredMetadataProvider] = useState(null); const [configuredAudiobookMetadataProvider, setConfiguredAudiobookMetadataProvider] = useState< diff --git a/src/frontend/src/hooks/app/useContentTypePreferences.ts b/src/frontend/src/hooks/app/useContentTypePreferences.ts index 4da4f08a..efe2c8c7 100644 --- a/src/frontend/src/hooks/app/useContentTypePreferences.ts +++ b/src/frontend/src/hooks/app/useContentTypePreferences.ts @@ -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>; 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(readInitialPreference); - const { contentType, combinedMode } = preference; + const [preference, setPreference] = useState(() => + resolveContentTypePreference(readStoredPreference(), null), + ); + const { contentType, combinedMode, source } = preference; const setContentType: Dispatch> = 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, diff --git a/src/frontend/src/tests/contentTypePreference.test.ts b/src/frontend/src/tests/contentTypePreference.test.ts new file mode 100644 index 00000000..8a2b8046 --- /dev/null +++ b/src/frontend/src/tests/contentTypePreference.test.ts @@ -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', + }); + }); +}); diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index f709bd29..3dfb6f44 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -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; diff --git a/src/frontend/src/utils/contentTypePreference.ts b/src/frontend/src/utils/contentTypePreference.ts new file mode 100644 index 00000000..9409430d --- /dev/null +++ b/src/frontend/src/utils/contentTypePreference.ts @@ -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' }; +}; diff --git a/tests/core/test_admin_users_api.py b/tests/core/test_admin_users_api.py index 1eb6497f..9fa8c253 100644 --- a/tests/core/test_admin_users_api.py +++ b/tests/core/test_admin_users_api.py @@ -1367,6 +1367,7 @@ class TestAdminSearchPreferences: assert data["keys"] == [ "SEARCH_MODE", "BOOK_LANGUAGE", + "DEFAULT_CONTENT_TYPE", "SHOW_COMBINED_SELECTOR", "FORCE_COMBINED_SEARCH", "METADATA_PROVIDER", diff --git a/tests/core/test_config_api.py b/tests/core/test_config_api.py index dc076798..4c9277e7 100644 --- a/tests/core/test_config_api.py +++ b/tests/core/test_config_api.py @@ -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):