diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index f6004db1..b1cf813e 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -204,6 +204,7 @@ register_group( # Direct mode sort options _AA_SORT_OPTIONS = [ + {"value": "", "label": "Most downloads"}, {"value": "relevance", "label": "Most relevant"}, {"value": "newest", "label": "Newest (publication year)"}, {"value": "oldest", "label": "Oldest (publication year)"}, @@ -483,7 +484,7 @@ def search_mode_settings() -> list[SettingsField]: label="Default Sort Order", description="Default sort order for search results.", options=_AA_SORT_OPTIONS, - default="relevance", + default="", show_when={"field": "SEARCH_MODE", "value": "direct"}, ), CheckboxField( diff --git a/shelfmark/main.py b/shelfmark/main.py index 7a63edf2..dc8dab52 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -1197,8 +1197,8 @@ def api_config() -> Response | tuple[Response, int]: "onboarding_complete": _get_onboarding_complete(), # Default sort orders "default_sort": app_config.get( - "AA_DEFAULT_SORT", "relevance" - ), # For direct mode (Anna's Archive) + "AA_DEFAULT_SORT", "" + ), # For direct mode (Anna's Archive) — empty means use local downloads sort "metadata_default_sort": get_provider_default_sort( metadata_ui_provider ), # For universal mode diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 054943f7..470565d9 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -478,6 +478,7 @@ function App() { updateAdvancedFilters, handleSearch, handleResetSearch, + reSortByDownloads, searchFieldValues, updateSearchFieldValue, searchFieldLabels, @@ -846,9 +847,7 @@ function App() { // Determine the default sort based on search mode const defaultSort = - cfg.search_mode === 'universal' - ? resolvedMetadataDefaultSort - : cfg.default_sort || 'relevance'; + cfg.search_mode === 'universal' ? resolvedMetadataDefaultSort : (cfg.default_sort ?? ''); if (cfg?.supported_formats) { // Seeding the defaults must not undo filters a shared link already applied. @@ -951,6 +950,7 @@ function App() { contentTypeOverride?: ContentType; searchModeOverride?: SearchMode; providerOverride?: string; + sort?: string; }) => { void refreshRequestPolicy(); void handleSearch({ @@ -960,6 +960,7 @@ function App() { contentTypeOverride: opts.contentTypeOverride, searchMode: opts.searchModeOverride, providerOverride: opts.providerOverride, + sort: opts.sort, }); }, [refreshRequestPolicy, handleSearch, config], @@ -2027,7 +2028,7 @@ function App() { const urlHashDefaultSort = effectiveSearchMode === 'universal' ? resolvedMetadataDefaultSort - : config?.default_sort || 'relevance'; + : (config?.default_sort ?? ''); // Keep the URL hash fragment live as search state changes. Gated until any URL-driven // bootstrap has applied (or there was nothing to apply), so we don't clobber a shared @@ -2360,6 +2361,7 @@ function App() { fieldValues: request.fieldValues, searchModeOverride: effectiveSearchMode, providerOverride: request.providerOverride, + sort: advancedFilters.sort, }); }, [ activeQueryOption, @@ -2583,11 +2585,19 @@ function App() { updateAdvancedFilters({ sort: request.appliedSort }); } setActiveResultsSort(request.appliedSort); + + // "Most downloads" is a client-side sort — just re-sort existing books + if (request.appliedSort === 'downloads' && effectiveSearchMode === 'direct') { + reSortByDownloads(); + return; + } + runSearchWithPolicyRefresh({ query: request.query, fieldValues: request.fieldValues, searchModeOverride: effectiveSearchMode, providerOverride: request.providerOverride, + sort: request.appliedSort, }); }} metadataSortOptions={resolvedMetadataSortOptions} diff --git a/src/frontend/src/components/UrlSearchBootstrapMount.tsx b/src/frontend/src/components/UrlSearchBootstrapMount.tsx index f44c4ba9..bdbdf576 100644 --- a/src/frontend/src/components/UrlSearchBootstrapMount.tsx +++ b/src/frontend/src/components/UrlSearchBootstrapMount.tsx @@ -39,6 +39,7 @@ interface UrlSearchBootstrapMountProps { contentTypeOverride?: ContentType; searchModeOverride?: SearchMode; fieldValues?: Record; + sort?: string; }) => void; onComplete: () => void; } @@ -203,6 +204,7 @@ export const UrlSearchBootstrapMount = ({ contentTypeOverride: urlContentTypeOverride, searchModeOverride: parsedSearchMode, fieldValues: urlFieldValues, + sort: parsedParams?.advancedFilters?.sort, }); }); diff --git a/src/frontend/src/data/filterOptions.ts b/src/frontend/src/data/filterOptions.ts index 3f218dbe..5162c4c5 100644 --- a/src/frontend/src/data/filterOptions.ts +++ b/src/frontend/src/data/filterOptions.ts @@ -1,6 +1,7 @@ // Direct download mode sort options export const SORT_OPTIONS = [ - { value: '', label: 'Most relevant' }, + { value: 'downloads', label: 'Most downloads' }, + { value: 'relevance', label: 'Most relevant' }, { value: 'newest', label: 'Newest (publication year)' }, { value: 'oldest', label: 'Oldest (publication year)' }, { value: 'largest', label: 'Largest (filesize)' }, diff --git a/src/frontend/src/hooks/useSearch.ts b/src/frontend/src/hooks/useSearch.ts index 62e68ab9..347d10a3 100644 --- a/src/frontend/src/hooks/useSearch.ts +++ b/src/frontend/src/hooks/useSearch.ts @@ -39,9 +39,11 @@ interface UseSearchReturn { contentTypeOverride?: ContentType; searchMode?: SearchMode; providerOverride?: string; + sort?: string; }) => Promise; handleResetSearch: (config: AppConfig | null) => void; resetSortFilter: () => void; + reSortByDownloads: () => void; // Universal mode search field values searchFieldValues: SearchFieldValues; updateSearchFieldValue: (key: string, value: string | number | boolean, label?: string) => void; @@ -106,6 +108,24 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { setAdvancedFilters((prev) => ({ ...prev, ...updates })); }, []); + // Sort books by downloads descending (no downloads go to the end) + const sortBooksByDownloads = useCallback((bookList: Book[]): Book[] => { + const sorted = bookList.toSorted((a, b) => { + const aDownloads = a.downloads ?? 0; + const bDownloads = b.downloads ?? 0; + // Books with downloads come first, sorted by count descending + if (aDownloads > 0 && bDownloads === 0) return -1; + if (aDownloads === 0 && bDownloads > 0) return 1; + return bDownloads - aDownloads; + }); + return sorted; + }, []); + + // Re-sort current books by downloads descending (client-side only, no search) + const reSortByDownloads = useCallback(() => { + setBooks((prev) => sortBooksByDownloads(prev)); + }, [sortBooksByDownloads]); + const updateSearchFieldValue = useCallback( (key: string, value: string | number | boolean, label?: string) => { setSearchFieldValues((prev) => ({ ...prev, [key]: value })); @@ -157,6 +177,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { contentTypeOverride, searchMode: searchModeOverride, providerOverride, + sort, }: { query: string; config: AppConfig | null; @@ -164,6 +185,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { contentTypeOverride?: ContentType; searchMode?: SearchMode; providerOverride?: string; + sort?: string; }) => { const effectiveContentType = contentTypeOverride ?? contentType; const searchMode = (searchModeOverride ?? config?.search_mode) || 'universal'; @@ -177,7 +199,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { const hasFieldValues = Object.values(effectiveFieldValues).some( (v) => v !== '' && v !== false, ); - const sort = params.get('sort') || 'relevance'; + const sortOrder = params.get('sort') || 'relevance'; if (!searchQuery && !hasFieldValues) { setBooks([]); @@ -202,7 +224,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { const result = await searchMetadata( searchQuery, 40, - sort, + sortOrder, effectiveFieldValues, 1, effectiveContentType, @@ -221,7 +243,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { // Store params for loadMore lastSearchParamsRef.current = { query: searchQuery, - sort, + sort: sortOrder, fieldValues: effectiveFieldValues, providerOverride, contentType: effectiveContentType, @@ -255,7 +277,13 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { const results = await searchBooks(query); if (results.length > 0) { - setBooks(results); + // When no explicit server-side sort is selected (empty or "downloads"), + // sort locally by downloads descending so the most popular results + // appear first. + const effectiveSort = sort ?? advancedFilters.sort; + const isDownloadsSort = !effectiveSort || effectiveSort === 'downloads'; + const sorted = isDownloadsSort ? sortBooksByDownloads(results) : results; + setBooks(sorted); } else { showToast('No results found', 'error'); } @@ -270,7 +298,14 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { setIsSearching(false); } }, - [showToast, searchFieldValues, handleSearchError, contentType], + [ + showToast, + searchFieldValues, + handleSearchError, + contentType, + advancedFilters, + sortBooksByDownloads, + ], ); const handleResetSearch = useCallback( @@ -367,6 +402,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { handleSearch, handleResetSearch, resetSortFilter, + reSortByDownloads, // Universal mode search field values searchFieldValues, updateSearchFieldValue, diff --git a/src/frontend/src/utils/buildSearchQuery.ts b/src/frontend/src/utils/buildSearchQuery.ts index e463a621..2e866f25 100644 --- a/src/frontend/src/utils/buildSearchQuery.ts +++ b/src/frontend/src/utils/buildSearchQuery.ts @@ -47,8 +47,11 @@ export const buildSearchQuery = ({ if (content) queryParts.push(`content=${encodeURIComponent(content)}`); formats.forEach((format) => queryParts.push(`format=${encodeURIComponent(format)}`)); - if (advancedFilters.sort) { - queryParts.push(`sort=${encodeURIComponent(advancedFilters.sort)}`); + // "downloads" is a client-side-only sort — send empty string so the server + // doesn't receive an invalid sort parameter. + const serverSort = advancedFilters.sort === 'downloads' ? '' : advancedFilters.sort; + if (serverSort) { + queryParts.push(`sort=${encodeURIComponent(serverSort)}`); } return queryParts.join('&');