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 authored and GitHub committed 2026-09-20 13:03:32 -04:00
1 parent c1315a2b23
commit d4619be69a
14 files changed
+181 -56

No files matched your search

+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