Files
shelfmark/shelfmark/release_sources/direct_download/source.py
T
Paul Rimmer d4619be69a 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 🤖
2026-09-20 13:03:32 -04:00

237 lines
8.8 KiB
Python

"""Direct Download search and release-source integration."""
import contextlib
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar
import requests
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import get_aa_content_type_dir
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.release_sources import (
BrowseRecord,
ColumnAlign,
ColumnColorHint,
ColumnRenderType,
ColumnSchema,
Release,
ReleaseColumnConfig,
ReleaseProtocol,
ReleaseSource,
SourceUnavailableError,
register_source,
)
from shelfmark.release_sources.direct_download import registry
from shelfmark.release_sources.direct_download.common import (
DirectDownloadUnavailableError,
RecordLookupProvider,
)
if TYPE_CHECKING:
from pathlib import Path
from shelfmark.core.models import DownloadTask
from shelfmark.core.search_plan import ReleaseSearchPlan
from shelfmark.metadata_providers import BookMetadata
logger = setup_logger(__name__)
def _extract_downloads(record: BrowseRecord) -> int | None:
"""Extract download count from record info for Release.extra.downloads."""
downloads = None
if record.info and "Downloads" in record.info:
downloads_value = record.info["Downloads"]
if isinstance(downloads_value, list) and len(downloads_value) > 0:
with contextlib.suppress(ValueError, TypeError):
downloads = int(downloads_value[0])
elif isinstance(downloads_value, (int, float)):
downloads = int(downloads_value)
return downloads
def _browse_record_to_release(record: BrowseRecord) -> Release:
"""Convert a browse record to a Release object.
This bridges the direct source's browse data to the generic release model.
"""
provider = registry.provider_for_record(record)
provider_id = provider.id if provider is not None else None
return Release(
source=record.source,
source_id=record.id,
title=record.title,
format=record.format,
language=record.language, # Top-level language for filtering
size=record.size,
download_url=record.source_url
or (record.download_urls[0] if record.download_urls else None),
info_url=record.source_url,
protocol=ReleaseProtocol.HTTP,
indexer="Direct Download",
content_type=record.content, # Preserve content type from source
extra={
"author": record.author,
"publisher": record.publisher,
"year": record.year,
"language": record.language,
"preview": record.preview,
"description": record.description,
"download_urls": record.download_urls,
"info": record.info,
"direct_download_provider": provider_id,
"downloads": _extract_downloads(record),
# Kept for older frontends and persisted request payloads.
"web_provider": provider_id if provider_id != "annas_archive" else None,
},
)
@register_source("direct_download")
class DirectDownloadSource(ReleaseSource):
"""Direct download source - searches web sources for books.
This wraps the search_books() functionality to provide releases
via the plugin interface.
"""
name = "direct_download"
display_name = "Direct Download"
supported_content_types: ClassVar[list[str]] = ["ebook"] # Direct downloads only support ebooks
def __init__(self) -> None:
"""Initialize per-instance search state for direct downloads."""
self._providers = registry.create_providers()
@property
def last_search_type(self) -> str:
"""Returns the search type used in the last search() call."""
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.
Shows language, format, size, and downloads for each release.
Language, format, size, and downloads are all shown on mobile.
"""
return ReleaseColumnConfig(
columns=[
ColumnSchema(
key="extra.language",
label="Language",
render_type=ColumnRenderType.BADGE,
align=ColumnAlign.CENTER,
width="60px",
hide_mobile=False, # Language shown on mobile
color_hint=ColumnColorHint(type="map", value="language"),
uppercase=True,
),
ColumnSchema(
key="format",
label="Format",
render_type=ColumnRenderType.BADGE,
align=ColumnAlign.CENTER,
width="80px",
hide_mobile=False, # Format shown on mobile
color_hint=ColumnColorHint(type="map", value="format"),
uppercase=True,
),
ColumnSchema(
key="size",
label="Size",
render_type=ColumnRenderType.SIZE,
align=ColumnAlign.CENTER,
width="80px",
hide_mobile=False, # Size shown on mobile
),
ColumnSchema(
key="extra.downloads",
label="Downloads",
render_type=ColumnRenderType.NUMBER,
align=ColumnAlign.CENTER,
width="80px",
hide_mobile=False, # Downloads shown on mobile
),
],
grid_template="minmax(0,2fr) 60px 80px 80px 80px",
supported_filters=["format", "language"], # AA has reliable language metadata
)
def get_record(
self,
record_id: str,
*,
fetch_download_count: bool = True,
) -> BrowseRecord | None:
"""Resolve a direct-download record for direct-mode info/download flows."""
provider = registry.provider_for_record_id(record_id, self._providers)
if provider is None or not isinstance(provider, RecordLookupProvider):
return None
native_id = record_id.partition(":")[2] or record_id
return provider.get_record(native_id, fetch_download_count=fetch_download_count)
def search_results_are_releases(self) -> bool:
"""Direct search results already represent concrete downloadable releases."""
return True
def get_destination_override(self, task: DownloadTask) -> Path | None:
"""Apply Anna's Archive content-type routing when configured."""
if check_audiobook(task.content_type):
return None
return get_aa_content_type_dir(task.content_type)
def search(
self,
book: BookMetadata,
plan: ReleaseSearchPlan,
*,
expand_search: bool = False,
content_type: str = "ebook",
) -> list[Release]:
"""Search every enabled provider through the shared provider lifecycle."""
unavailable_reason = registry.get_unavailable_reason(self._providers)
if unavailable_reason:
raise DirectDownloadUnavailableError(unavailable_reason)
releases: list[Release] = []
failures: list[Exception] = []
for provider in registry.enabled_providers(self._providers):
try:
records = provider.search(
book,
plan,
expand_search=expand_search,
content_type=content_type,
)
except SourceUnavailableError as exc:
failures.append(exc)
continue
except (
RuntimeError,
TypeError,
ValueError,
requests.exceptions.RequestException,
) as exc:
logger.warning("%s search failed: %s", provider.display_name, exc)
failures.append(exc)
continue
releases.extend(_browse_record_to_release(record) for record in records)
# A provider failure is only quiet when another provider answered. Otherwise the
# caller has to see it, or a failed search reads as a search with no hits.
if failures and not releases:
raise failures[0]
return releases
def is_available(self) -> bool:
"""Check if Direct Download has been explicitly enabled and configured."""
return registry.get_unavailable_reason(self._providers) is None