From d4619be69a796cf51a8da4e268abe51b621193fc Mon Sep 17 00:00:00 2001 From: Paul Rimmer Date: Mon, 21 Sep 2026 00:03:32 +0700 Subject: [PATCH] Feature: Show the AA search result stats (#1362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: direct-results-stat universal-results-info Coded with llama.cpp, opencode and 🤖 --- shelfmark/download/warmup.py | 2 +- shelfmark/main.py | 7 +- .../direct_download/annas_archive.py | 55 ++++++++++-- .../release_sources/direct_download/source.py | 6 ++ src/frontend/src/App.tsx | 2 + src/frontend/src/components/ReleaseModal.tsx | 32 ++++++- .../src/components/ResultsSection.tsx | 85 +++++++++++++------ .../releaseModal/useReleaseSearchSession.ts | 1 + src/frontend/src/hooks/useSearch.ts | 10 ++- src/frontend/src/services/api.ts | 12 ++- src/frontend/src/types/index.ts | 1 + tests/direct_download/test_search_budget.py | 6 +- tests/direct_download/test_search_queries.py | 16 ++-- tests/download/test_warmup_yields_to_user.py | 2 +- 14 files changed, 181 insertions(+), 56 deletions(-) diff --git a/shelfmark/download/warmup.py b/shelfmark/download/warmup.py index 98c2cf74..de6115bb 100644 --- a/shelfmark/download/warmup.py +++ b/shelfmark/download/warmup.py @@ -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. diff --git a/shelfmark/main.py b/shelfmark/main.py index 83fdbd2b..c60d2bdd 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -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, diff --git a/shelfmark/release_sources/direct_download/annas_archive.py b/shelfmark/release_sources/direct_download/annas_archive.py index 1d2224a4..55eed505 100644 --- a/shelfmark/release_sources/direct_download/annas_archive.py +++ b/shelfmark/release_sources/direct_download/annas_archive.py @@ -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) diff --git a/shelfmark/release_sources/direct_download/source.py b/shelfmark/release_sources/direct_download/source.py index 040e0420..ee4acfa2 100644 --- a/shelfmark/release_sources/direct_download/source.py +++ b/shelfmark/release_sources/direct_download/source.py @@ -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. diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 470565d9..bd72c271 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -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} /> diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index 89b17979..9ee851a2 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -1769,7 +1769,7 @@ const ReleaseModalSession = ({ )} {!sourcesLoading && allTabs.length > 0 && ( -
+
{/* Tabs - scrollable on narrow screens */}
@@ -1801,7 +1801,35 @@ const ReleaseModalSession = ({
-
+ {/* Center: Results count from release sources */} +
+ {(() => { + 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 ( + + Result 1 (1 Total) + + ); + } + const shownEnd = Math.min(totalCountNum, 50); + const totalStr = isCapped ? '500+' : String(totalCountNum); + return ( + + Results 1-{shownEnd} ({totalStr} Total) + + ); + })()} +
+ + {/* Right: Controls */} +
{/* Multi-book pack toggle (fallback for releases that can't be inspected) */} {!isCombinedMode && (