Feature: Show the AA search result stats (#1362)

When doing a direct search for a book, show the stats of the AA results.
For example if we search for "The Great Gatsby", AA reports it has 240
hits and shows the first page of 50. Provide this stats info in the
shelfmark webUI via ResultsSection.tsx and ReleaseModal.tsx. This table
shows what should be displayed based on the total number of hits found:

|Total	|Display|
|-------|-------|
|1	|"Result 1 (1 Total)"|
|6	|"Results 1-6 (6 Total)"|
|144	|"Results 1-50 (144 Total)"|
|500+	|"Results 1-50 (500+ Total)"|

Currently, shelfmark also only shows us the first 50 search hits even if
there were more available from AA. This could be added later if
considered desirable.

As usual, a picture is worth a 1000 words:

<img width="1012" height="610" alt="direct-results-stat"
src="https://github.com/user-attachments/assets/8c0c688f-444e-482a-a9a3-2dee5a5563b8"
/>

<img width="1013" height="741" alt="universal-results-info"
src="https://github.com/user-attachments/assets/399bbb79-87f2-4432-a3d0-64937795f5f1"
/>

Coded with llama.cpp, opencode and 🤖
This commit is contained in:
Paul Rimmer
2026-09-20 13:03:32 -04:00
committed by GitHub
parent c1315a2b23
commit d4619be69a
14 changed files with 181 additions and 56 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ def run_warmup() -> bool:
from shelfmark.core.models import SearchFilters
from shelfmark.release_sources.direct_download import search_books
results = search_books(query, SearchFilters())
results, _ = search_books(query, SearchFilters())
except Exception:
# Broad by design: a warm-up must never take the app down, and the source
# raises everything from network errors to parse failures.
+6 -1
View File
@@ -3049,8 +3049,13 @@ def api_releases() -> Response | tuple[Response, int]:
search_info = {}
for source_name, source_instance in source_instances.items():
info: dict[str, str | int | None] = {}
if hasattr(source_instance, "last_search_type") and source_instance.last_search_type:
search_info[source_name] = {"search_type": source_instance.last_search_type}
info["search_type"] = source_instance.last_search_type
if hasattr(source_instance, "total_results"):
info["total_results"] = source_instance.total_results
if info:
search_info[source_name] = info
response = {
"releases": releases_data,
@@ -687,7 +687,23 @@ def _fetch_search_table_uncached(
return "", None
def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]:
def _extract_total_results_from_html(html: str) -> int | str | None:
"""Extract the total results count from Anna's Archive search page HTML.
AA renders a summary like: Results 1-50 (92 total)
Or for 500+ results: Results 1-50 (500+ total)
Returns the total count as int, or "500+" for capped results, or None if not found.
"""
match = re.search(r"Results\s+\d+\s*-\s*\d+\s*\((\d+(?:\+\s*)?)\s+total\)", html)
if match:
raw = match.group(1)
if "+" in raw:
return "500+"
return int(raw)
return None
def search_books(query: str, filters: SearchFilters) -> tuple[list[BrowseRecord], int | str | None]:
"""Search for books matching the query.
Args:
@@ -695,7 +711,7 @@ def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]:
filters: Search filters (language, format, content type, etc.)
Returns:
List[BrowseRecord]: List of matching books
Tuple of (List[BrowseRecord], total_count | None): Matching books and total result count
Raises:
SearchUnavailableError: If Anna's Archive cannot be reached
@@ -752,10 +768,11 @@ def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]:
# AA gates /search behind a DDoS-Guard JS challenge, which every mirror shares. Rotating
# to another mirror only collects another 403, so let the bypasser solve it.
html, tbody = _fetch_search_table(url, selector)
total_count = _extract_total_results_from_html(html) if tbody is not None else None
if tbody is None:
if "No files found." in html:
logger.info("No books found for query: %s", query)
return []
return ([], None)
logger.warning("No results table found for query: %s", query)
msg = "No books found. Please try another query."
raise RuntimeError(msg)
@@ -791,7 +808,7 @@ def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]:
if books:
_enrich_search_results_with_downloads(books)
return books
return (books, total_count)
def _fetch_download_count_inline(book_id: str) -> int | None:
@@ -1897,11 +1914,16 @@ class AnnasArchiveProvider:
def __init__(self) -> None:
self._last_search_type = "title_author"
self._total_results: int | str | None = None
@property
def last_search_type(self) -> str:
return self._last_search_type
@property
def total_results(self) -> int | str | None:
return self._total_results
def is_enabled(self) -> bool:
from shelfmark.core import mirrors
@@ -1940,8 +1962,9 @@ class AnnasArchiveProvider:
search_label: str,
) -> list[BrowseRecord]:
"""Retry AA queries without a language filter when filtered search returns nothing."""
results = search_books(query, filters)
results, total_count = self._search_books(query, filters)
if results or not filters.lang:
self._total_results = total_count
return results
logger.debug(
@@ -1949,7 +1972,17 @@ class AnnasArchiveProvider:
search_label,
filters.lang,
)
return search_books(query, replace(filters, lang=None))
results, total_count = self._search_books(query, replace(filters, lang=None))
self._total_results = total_count
return results
def _search_books(
self,
query: str,
filters: SearchFilters,
) -> tuple[list[BrowseRecord], int | str | None]:
"""Call search_books and capture the total result count."""
return search_books(query, filters)
def search(
self,
@@ -2019,10 +2052,11 @@ class AnnasArchiveProvider:
filters = SearchFilters(isbn=[isbn])
filters.lang = lang_filter if lang_filter is not None else []
try:
results = search_books(isbn, filters)
results, isbn_total = search_books(isbn, filters)
if results:
logger.info("Found %s releases via ISBN", len(results))
self._last_search_type = "isbn"
self._total_results = isbn_total
return results
logger.debug("No ISBN results, falling back to title+author")
except SearchUnavailableError:
@@ -2052,7 +2086,9 @@ class AnnasArchiveProvider:
logger.debug("Searching direct_download: title_author='%s', langs=%s", query, langs)
filters = SearchFilters(lang=langs if langs is not None else [])
try:
for bi in search_books(query, filters):
books, title_total = search_books(query, filters)
self._total_results = title_total
for bi in books:
if bi.id not in seen_ids:
seen_ids.add(bi.id)
all_results.append(bi)
@@ -2079,7 +2115,8 @@ class AnnasArchiveProvider:
logger.debug("Searching direct_download: title_author='%s', langs=[]", query)
try:
for bi in search_books(query, SearchFilters()):
books, _ = search_books(query, SearchFilters())
for bi in books:
if bi.id not in seen_ids:
seen_ids.add(bi.id)
all_results.append(bi)
@@ -110,6 +110,12 @@ class DirectDownloadSource(ReleaseSource):
provider = registry.provider_by_id("annas_archive", self._providers)
return str(getattr(provider, "last_search_type", "title_author"))
@property
def total_results(self) -> int | None:
"""Returns the total result count from the last search."""
provider = registry.provider_by_id("annas_archive", self._providers)
return getattr(provider, "total_results", None)
def get_column_config(self) -> ReleaseColumnConfig:
"""Column configuration for Direct Download source.
+2
View File
@@ -487,6 +487,7 @@ function App() {
isLoadingMore,
loadMore,
totalFound,
directTotalResults,
resultsSourceUrl,
} = useSearch({
showToast,
@@ -2607,6 +2608,7 @@ function App() {
void loadMore(config, effectiveSearchMode);
}}
totalFound={totalFound}
directTotalResults={directTotalResults}
onShowToast={showToast}
resultsSourceUrl={resultsSourceUrl}
/>
+30 -2
View File
@@ -1769,7 +1769,7 @@ const ReleaseModalSession = ({
</div>
)}
{!sourcesLoading && allTabs.length > 0 && (
<div className="flex items-center justify-between px-5">
<div className="flex items-center px-5">
{/* Tabs - scrollable on narrow screens */}
<div className="scrollbar-hide min-w-0 flex-1 overflow-x-auto">
<div className="relative flex gap-1">
@@ -1801,7 +1801,35 @@ const ReleaseModalSession = ({
</div>
</div>
<div className="flex items-center gap-3 pr-1 pl-2">
{/* Center: Results count from release sources */}
<div className="flex flex-1 justify-center">
{(() => {
const searchInfo = releasesBySource[activeTab]?.search_info?.[activeTab];
const totalCount = searchInfo?.total_results;
if (totalCount === undefined || totalCount === null || totalCount === 0)
return null;
const isCapped = totalCount === '500+';
const totalCountNum = isCapped ? 500 : Number(totalCount);
// AA-style: show page range + total
if (totalCountNum === 1) {
return (
<span className="mx-2 shrink-0 text-xs whitespace-nowrap text-gray-500 dark:text-gray-400">
Result 1 (1 Total)
</span>
);
}
const shownEnd = Math.min(totalCountNum, 50);
const totalStr = isCapped ? '500+' : String(totalCountNum);
return (
<span className="mx-2 shrink-0 text-xs whitespace-nowrap text-gray-500 dark:text-gray-400">
Results 1-{shownEnd} ({totalStr} Total)
</span>
);
})()}
</div>
{/* Right: Controls */}
<div className="ml-auto flex items-center gap-3 pr-1 pl-2">
{/* Multi-book pack toggle (fallback for releases that can't be inspected) */}
{!isCombinedMode && (
<button
+58 -27
View File
@@ -33,6 +33,8 @@ interface ResultsSectionProps {
isLoadingMore?: boolean;
onLoadMore?: () => void;
totalFound?: number;
// Direct mode: total result count from release sources
directTotalResults?: number | string | null;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
resultsSourceUrl?: string;
}
@@ -53,6 +55,7 @@ export const ResultsSection = ({
isLoadingMore,
onLoadMore,
totalFound,
directTotalResults,
onShowToast,
resultsSourceUrl,
}: ResultsSectionProps) => {
@@ -88,34 +91,62 @@ export const ResultsSection = ({
return (
<section id="results-section" className="mb-4 w-full sm:mb-8">
<div className="relative z-10 mb-2 flex items-center justify-between sm:mb-3">
{showSortControl && (
<SortControl
value={sortValue}
onChange={onSortChange}
metadataSortOptions={metadataSortOptions}
/>
)}
{!showSortControl && resultsSourceUrl && (
<a
href={resultsSourceUrl}
target="_blank"
rel="noopener noreferrer"
className="animate-pop-up inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
>
View list on Hardcover
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
)}
<div className="relative z-10 mb-2 flex items-center sm:mb-3">
{/* Left: Sort control or Hardcover link */}
<div>
{showSortControl && (
<SortControl
value={sortValue}
onChange={onSortChange}
metadataSortOptions={metadataSortOptions}
/>
)}
{!showSortControl && resultsSourceUrl && (
<a
href={resultsSourceUrl}
target="_blank"
rel="noopener noreferrer"
className="animate-pop-up inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
>
View list on Hardcover
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
)}
</div>
{/* View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */}
{/* Center: Results count */}
<div className="flex flex-1 justify-center">
{(() => {
const count = directTotalResults ?? null;
if (count === null || count === 0) return null;
const isCapped = directTotalResults === '500+';
const totalCount = isCapped ? 500 : Number(count);
// AA-style: show page range + total
if (totalCount === 1) {
return (
<span className="mx-2 mt-4 px-2 text-xs whitespace-nowrap text-gray-500 dark:text-gray-400">
Result 1 (1 Total)
</span>
);
}
const shownEnd = Math.min(totalCount, 50);
const totalStr = isCapped ? '500+' : String(totalCount);
return (
<span className="mx-2 mt-4 px-2 text-xs whitespace-nowrap text-gray-500 dark:text-gray-400">
Results 1-{shownEnd} ({totalStr} Total)
</span>
);
})()}
</div>
{/* Right: View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */}
<div className="ml-auto flex items-center gap-2">
{isDesktop && (
<button
@@ -288,6 +288,7 @@ export function useReleaseSearchSession(
[tabName]: {
...existing,
releases: [...existing.releases, ...mergedReleases],
search_info: response.search_info,
},
};
});
+9 -1
View File
@@ -53,6 +53,8 @@ interface UseSearchReturn {
isLoadingMore: boolean;
loadMore: (config: AppConfig | null, searchMode?: SearchMode) => Promise<void>;
totalFound: number;
// Direct mode: total result count from release sources (e.g., "500+" for capped)
directTotalResults: number | string | null;
// Source URL and title for the current result set (e.g. Hardcover list page)
resultsSourceUrl: string | undefined;
resultsSourceTitle: string | undefined;
@@ -92,6 +94,8 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [totalFound, setTotalFound] = useState(0);
// Direct mode: total result count from release sources (e.g., Anna's Archive)
const [directTotalResults, setDirectTotalResults] = useState<number | string | null>(null);
const [resultsSourceUrl, setResultsSourceUrl] = useState<string | undefined>();
const [resultsSourceTitle, setResultsSourceTitle] = useState<string | undefined>();
@@ -274,7 +278,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setLastSearchQuery(query);
try {
const results = await searchBooks(query);
const { books: results, totalResults } = await searchBooks(query);
if (results.length > 0) {
// When no explicit server-side sort is selected (empty or "downloads"),
@@ -284,8 +288,10 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
const isDownloadsSort = !effectiveSort || effectiveSort === 'downloads';
const sorted = isDownloadsSort ? sortBooksByDownloads(results) : results;
setBooks(sorted);
setDirectTotalResults(totalResults);
} else {
showToast('No results found', 'error');
setDirectTotalResults(null);
}
} catch (error) {
if (error instanceof AuthenticationError) {
@@ -335,6 +341,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setCurrentPage(1);
setHasMore(false);
setTotalFound(0);
setDirectTotalResults(null);
setResultsSourceUrl(undefined);
setResultsSourceTitle(undefined);
lastSearchParamsRef.current = null;
@@ -412,6 +419,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
isLoadingMore,
loadMore,
totalFound,
directTotalResults,
resultsSourceUrl,
resultsSourceTitle,
};
+9 -3
View File
@@ -279,14 +279,20 @@ async function fetchJSON<T>(
}
// API functions
export const searchBooks = async (query: string): Promise<Book[]> => {
if (!query) return [];
export const searchBooks = async (
query: string,
): Promise<{ books: Book[]; totalResults: number | string | null }> => {
if (!query) return { books: [], totalResults: null };
const response = await fetchJSON<ReleasesResponse>(
`${API_BASE}/releases?source=direct_download&${query}`,
{},
searchTimeoutMs,
);
return response.releases.map(transformReleaseToDirectBook);
// Extract total count from search_info if available
const firstSource = response.sources_searched?.[0];
const searchInfo = firstSource ? response.search_info?.[firstSource] : undefined;
const totalResults = searchInfo?.total_results ?? null;
return { books: response.releases.map(transformReleaseToDirectBook), totalResults };
};
// Metadata search response type (internal)
+1
View File
@@ -475,6 +475,7 @@ export interface Release {
// Search info returned by release sources
export interface SourceSearchInfo {
search_type: 'isbn' | 'title_author' | 'categories' | 'expanded' | 'manual' | 'query';
total_results?: number | string | null;
}
// Response from /api/releases endpoint
+3 -3
View File
@@ -99,7 +99,7 @@ def test_title_variants_stop_once_the_budget_is_spent(monkeypatch):
deadline = search_deadline.current()
if deadline is not None:
deadline.event.set()
return []
return ([], None)
monkeypatch.setattr(aa, "search_books", fake_search_books)
monkeypatch.setattr(aa, "ensure_available", lambda: None)
@@ -114,7 +114,7 @@ def test_title_variants_stop_once_the_budget_is_spent(monkeypatch):
def test_all_title_variants_run_within_budget(monkeypatch):
queries: list[str] = []
monkeypatch.setattr(aa, "search_books", lambda q, _f: queries.append(q) or [])
monkeypatch.setattr(aa, "search_books", lambda q, _f: queries.append(q) or ([], None))
monkeypatch.setattr(aa, "ensure_available", lambda: None)
source = dd.DirectDownloadSource()
@@ -135,7 +135,7 @@ def test_language_filter_retry_is_skipped_on_a_spent_budget(monkeypatch):
deadline = search_deadline.current()
if deadline is not None:
deadline.event.set()
return []
return ([], None)
monkeypatch.setattr(aa, "search_books", fake_search_books)
monkeypatch.setattr(aa, "ensure_available", lambda: None)
+8 -8
View File
@@ -33,7 +33,7 @@ class TestDirectDownloadSearchQueries:
def fake_search_books(query: str, filters):
captured.append(query)
return []
return ([], None)
_enable_direct_download(monkeypatch)
@@ -75,7 +75,7 @@ class TestDirectDownloadSearchQueries:
def fake_search_books(query: str, filters):
captured.append((query, filters.lang))
return records_by_query[query]
return (records_by_query[query], None)
_enable_direct_download(monkeypatch)
@@ -118,8 +118,8 @@ class TestDirectDownloadSearchQueries:
def fake_search_books(query: str, filters):
captured.append((query, filters.lang))
if filters.lang:
return []
return fallback_results[query]
return ([], None)
return (fallback_results[query], None)
_enable_direct_download(monkeypatch)
@@ -156,8 +156,8 @@ class TestDirectDownloadSearchQueries:
def fake_search_books(query: str, filters):
captured.append((query, filters.lang, filters.format))
if filters.lang:
return []
return [_browse_record("manual-1", "Manual result")]
return ([], None)
return ([_browse_record("manual-1", "Manual result")], None)
_enable_direct_download(monkeypatch)
@@ -336,7 +336,7 @@ def test_search_books_filters_locally_when_path_language_enabled(monkeypatch):
monkeypatch.setattr(aa.downloader, "html_get_page", _fake_html_get_page)
records = aa.search_books("demo", SearchFilters(lang=["fr"], format=["pdf"]))
records, _ = aa.search_books("demo", SearchFilters(lang=["fr"], format=["pdf"]))
assert "&lang=" not in captured_url["url"]
assert len(records) == 1
@@ -380,7 +380,7 @@ def test_search_books_keeps_server_language_matches_when_path_language_disabled(
monkeypatch.setattr(aa.downloader, "html_get_page", _fake_html_get_page)
records = aa.search_books("demo", SearchFilters(lang=["en"], format=["pdf"]))
records, _ = aa.search_books("demo", SearchFilters(lang=["en"], format=["pdf"]))
# AA already narrowed by &lang=; its free-text language cells must not be re-matched.
assert "&lang=en" in captured_url["url"]
+1 -1
View File
@@ -22,7 +22,7 @@ def test_warmup_runs_when_nobody_has_searched(monkeypatch):
searched: list[str] = []
monkeypatch.setattr(
"shelfmark.release_sources.direct_download.search_books",
lambda query, _filters: searched.append(query) or ["a result"],
lambda query, _filters: (searched.append(query), ["a result"], None)[1:],
)
assert warmup.run_warmup() is True