Added the ability to sort direct search results by Most downloads (#1351)

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:

<img width="1186" height="759"
alt="search-mode-sort-order-most-downloads"
src="https://github.com/user-attachments/assets/c43fe2e7-84ba-48cd-a850-383f1af0d28f"
/>

<img width="1172" height="828"
alt="direct-search-results-most-downloads"
src="https://github.com/user-attachments/assets/4e240180-6a9d-4ce1-ab86-006b286570dd"
/>

Coded with llama.cpp, opencode and 🤖
This commit is contained in:
Paul Rimmer
2026-09-19 23:12:36 -04:00
committed by GitHub
parent cdd001bdd9
commit aafce2be1d
7 changed files with 68 additions and 15 deletions
+2 -1
View File
@@ -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(
+2 -2
View File
@@ -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
+14 -4
View File
@@ -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}
@@ -39,6 +39,7 @@ interface UrlSearchBootstrapMountProps {
contentTypeOverride?: ContentType;
searchModeOverride?: SearchMode;
fieldValues?: Record<string, string | number | boolean>;
sort?: string;
}) => void;
onComplete: () => void;
}
@@ -203,6 +204,7 @@ export const UrlSearchBootstrapMount = ({
contentTypeOverride: urlContentTypeOverride,
searchModeOverride: parsedSearchMode,
fieldValues: urlFieldValues,
sort: parsedParams?.advancedFilters?.sort,
});
});
+2 -1
View File
@@ -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)' },
+41 -5
View File
@@ -39,9 +39,11 @@ interface UseSearchReturn {
contentTypeOverride?: ContentType;
searchMode?: SearchMode;
providerOverride?: string;
sort?: string;
}) => Promise<void>;
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,
+5 -2
View File
@@ -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('&');