mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 13:40:21 +01:00
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 🤖
237 lines
8.8 KiB
Python
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
|