From aafce2be1dabe891d30eabd8aa85b4672808ef2a Mon Sep 17 00:00:00 2001 From: Paul Rimmer Date: Sun, 20 Sep 2026 10:12:36 +0700 Subject: [PATCH] Added the ability to sort direct search results by Most downloads (#1351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that we know about the AA Downloads stats for search results (#1336), this PR allows sorting them locally by "Most downloads" without doing another AA search. Not needing to do an AA search for this is faster than the other sorts which do new AA searches asking for their results to be sorted a certain way. Also added the "Settings->Search Mode->Default Sort Order->Most downloads" option. Universal downloads do not allow for results sorting so I did not include this feature there. NOTE: While adding this feature I noticed that shelfmark only looks at the first page of AA search results. So switching between different sort orders may result in different sets of results with the same search term. Here's a couple of pics showing the new sort option: search-mode-sort-order-most-downloads direct-search-results-most-downloads Coded with llama.cpp, opencode and 🤖 --- shelfmark/config/settings.py | 3 +- shelfmark/main.py | 4 +- src/frontend/src/App.tsx | 18 ++++++-- .../components/UrlSearchBootstrapMount.tsx | 2 + src/frontend/src/data/filterOptions.ts | 3 +- src/frontend/src/hooks/useSearch.ts | 46 +++++++++++++++++-- src/frontend/src/utils/buildSearchQuery.ts | 7 ++- 7 files changed, 68 insertions(+), 15 deletions(-) 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('&');