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 && (