From 8bb188c903120949d4d0871e3de11460c46cd232 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:59:37 +0000 Subject: [PATCH] Refactor direct source to use universal API (#711) --- shelfmark/core/__init__.py | 2 +- shelfmark/core/models.py | 49 --- shelfmark/core/request_policy.py | 24 +- shelfmark/core/request_routes.py | 32 +- shelfmark/core/search_plan.py | 5 + shelfmark/download/orchestrator.py | 131 ++------ shelfmark/download/postprocess/destination.py | 13 +- shelfmark/main.py | 299 +++++++++--------- shelfmark/release_sources/__init__.py | 91 ++++++ shelfmark/release_sources/direct_download.py | 136 +++++--- src/frontend/src/App.tsx | 62 ++-- src/frontend/src/components/ReleaseModal.tsx | 74 +++-- .../components/RequestConfirmationModal.tsx | 3 +- src/frontend/src/hooks/requestPolicyCore.ts | 24 +- src/frontend/src/services/api.ts | 31 +- .../src/tests/bookTransformers.node.test.ts | 83 +++++ .../src/tests/requestFulfil.node.test.ts | 2 + .../src/tests/requestPayload.node.test.ts | 44 +++ .../src/tests/requestPolicyCore.node.test.ts | 10 +- src/frontend/src/types/index.ts | 8 +- src/frontend/src/utils/bookTransformers.ts | 102 +++++- src/frontend/src/utils/requestFulfil.ts | 1 + src/frontend/src/utils/requestPayload.ts | 45 ++- tests/core/test_download_api_guardrails.py | 198 +----------- tests/core/test_oidc_integration.py | 2 +- tests/core/test_per_user_downloads.py | 16 +- .../core/test_releases_api_direct_provider.py | 196 ++++++++++-- tests/core/test_request_routes_api.py | 45 ++- tests/direct_download/test_handler.py | 36 +++ .../test_orchestrator_user_output_mode.py | 85 +---- tests/e2e/test_api.py | 56 ++-- tests/e2e/test_download_flow.py | 44 +-- tests/e2e/test_proxy_auth_middleware.py | 22 +- 33 files changed, 1121 insertions(+), 850 deletions(-) create mode 100644 src/frontend/src/tests/bookTransformers.node.test.ts create mode 100644 tests/direct_download/test_handler.py diff --git a/shelfmark/core/__init__.py b/shelfmark/core/__init__.py index f55c6a04..46eb8a0d 100644 --- a/shelfmark/core/__init__.py +++ b/shelfmark/core/__init__.py @@ -1,5 +1,5 @@ """Core module - shared models, queue, and utilities.""" -from shelfmark.core.models import BookInfo, QueueItem, SearchFilters, QueueStatus +from shelfmark.core.models import QueueItem, SearchFilters, QueueStatus from shelfmark.core.queue import BookQueue, book_queue from shelfmark.core.logger import setup_logger diff --git a/shelfmark/core/models.py b/shelfmark/core/models.py index 1fcd01b7..53be667b 100644 --- a/shelfmark/core/models.py +++ b/shelfmark/core/models.py @@ -131,55 +131,6 @@ class DownloadTask: return build_filename(self.title, self.author, self.year, self.format) -@dataclass -class BookInfo: - """Data class representing book information.""" - id: str - title: str - preview: Optional[str] = None - author: Optional[str] = None - publisher: Optional[str] = None - year: Optional[str] = None - language: Optional[str] = None - content: Optional[str] = None - format: Optional[str] = None - size: Optional[str] = None - info: Optional[Dict[str, List[str]]] = None - description: Optional[str] = None - download_urls: List[str] = field(default_factory=list) - download_path: Optional[str] = None - priority: int = 0 - progress: Optional[float] = None - status_message: Optional[str] = None # Detailed status message for UI display - added_time: Optional[float] = None # Timestamp when added to queue - source: str = "direct_download" # Release source handler to use for downloads - source_url: Optional[str] = None # Link to source page (e.g., Anna's Archive) - - def get_filename(self, fallback_url: Optional[str] = None) -> str: - """Build sanitized filename: 'Author - Title (Year).format' - - Resolves format from self.format, download_urls, or fallback_url. - - Args: - fallback_url: URL to extract format from if not already known - - Returns: - Sanitized filename safe for filesystem use - """ - # Resolve format if needed - if not self.format: - urls = [self.download_urls[0]] if self.download_urls else [] - if fallback_url: - urls.append(fallback_url) - for url in urls: - ext = url.split(".")[-1].lower() - if ext and len(ext) <= 5 and ext.isalnum(): - self.format = ext - break - - return build_filename(self.title, self.author, self.year, self.format) - - @dataclass class SearchFilters: """Filters for book search queries.""" diff --git a/shelfmark/core/request_policy.py b/shelfmark/core/request_policy.py index d3004e12..7744b15f 100644 --- a/shelfmark/core/request_policy.py +++ b/shelfmark/core/request_policy.py @@ -43,9 +43,17 @@ def cap_mode(mode: PolicyMode, ceiling: PolicyMode) -> PolicyMode: return mode -def _normalize_direct_source_mode(source: Any, mode: PolicyMode) -> PolicyMode: - """Direct search results are concrete releases; normalize request_book to request_release.""" - if normalize_source(source) == "direct_download" and mode == PolicyMode.REQUEST_BOOK: +def _source_results_are_releases(source: Any) -> bool: + normalized_source = normalize_source(source) + if normalized_source in {"", "*"}: + return False + from shelfmark.release_sources import source_results_are_releases + return source_results_are_releases(normalized_source) + + +def _normalize_release_result_mode(source: Any, mode: PolicyMode) -> PolicyMode: + """Concrete release browse results cannot fall back to request_book semantics.""" + if mode == PolicyMode.REQUEST_BOOK and _source_results_are_releases(source): return PolicyMode.REQUEST_RELEASE return mode @@ -328,9 +336,9 @@ def resolve_policy_mode( The content-type default acts as a ceiling — matrix rules can only match or restrict further, never upgrade beyond the default. - Direct-download exception: - - direct_download results are concrete releases, so request_book is - normalized to request_release for that source. + Concrete-release browse exception: + - sources whose browse results are already concrete releases normalize + request_book to request_release. """ effective = merge_request_policy_settings(global_settings, user_settings) @@ -357,9 +365,9 @@ def resolve_policy_mode( for candidate_source, candidate_content_type in candidates: for rule_source, rule_content_type, rule_mode in rules: if rule_source == candidate_source and rule_content_type == candidate_content_type: - return _normalize_direct_source_mode( + return _normalize_release_result_mode( normalized_source, cap_mode(rule_mode, ceiling), ) - return _normalize_direct_source_mode(normalized_source, ceiling) + return _normalize_release_result_mode(normalized_source, ceiling) diff --git a/shelfmark/core/request_routes.py b/shelfmark/core/request_routes.py index 8714890a..3f0e13a9 100644 --- a/shelfmark/core/request_routes.py +++ b/shelfmark/core/request_routes.py @@ -132,17 +132,18 @@ def _normalize_optional_source_id(value: Any) -> str | None: return normalize_optional_text(value) -def _build_direct_release_data_from_book_data( +def _build_release_result_data_from_book_data( *, + source: str, book_data: dict[str, Any], content_type: str, ) -> dict[str, Any]: - """Build release-level payload fields for direct-download requests.""" + """Build release-level payload fields for sources whose browse results are releases.""" source_id = _normalize_optional_source_id(book_data.get("provider_id")) or _normalize_optional_source_id( book_data.get("id") ) payload: dict[str, Any] = { - "source": "direct_download", + "source": source, "source_id": source_id, "title": book_data.get("title"), "author": book_data.get("author"), @@ -151,11 +152,21 @@ def _build_direct_release_data_from_book_data( "size": book_data.get("size"), "preview": book_data.get("preview"), "content_type": content_type, + "source_url": book_data.get("source_url"), + "search_mode": "direct", } return {key: value for key, value in payload.items() if value is not None} -def _normalize_direct_request_payload( +def _source_results_are_releases(source: str) -> bool: + normalized_source = normalize_source(source) + if normalized_source in {"", "*"}: + return False + from shelfmark.release_sources import source_results_are_releases + return source_results_are_releases(normalized_source) + + +def _normalize_release_result_request_payload( *, source: str, request_level: Any, @@ -163,13 +174,14 @@ def _normalize_direct_request_payload( release_data: Any, content_type: str, ) -> tuple[Any, Any]: - """Direct-search requests are always release-level with direct source metadata.""" - if source != "direct_download": + """Concrete-release browse results are always handled as release-level requests.""" + if not _source_results_are_releases(source): return request_level, release_data normalized_release_data = release_data if normalized_release_data is None and isinstance(book_data, dict): - normalized_release_data = _build_direct_release_data_from_book_data( + normalized_release_data = _build_release_result_data_from_book_data( + source=source, book_data=book_data, content_type=content_type, ) @@ -177,7 +189,7 @@ def _normalize_direct_request_payload( normalized_release_data = dict(normalized_release_data) if isinstance(normalized_release_data, dict): - normalized_release_data["source"] = "direct_download" + normalized_release_data["source"] = source if normalized_release_data.get("content_type") is None: normalized_release_data["content_type"] = content_type @@ -320,6 +332,7 @@ def register_request_routes( default_audio_mode = parse_policy_mode(effective.get("REQUEST_POLICY_DEFAULT_AUDIOBOOK")) source_capabilities = get_source_content_type_capabilities() + from shelfmark.release_sources import source_results_are_releases source_modes = [] for source_name in sorted(source_capabilities): supported_types = sorted( @@ -339,6 +352,7 @@ def register_request_routes( { "source": source_name, "supported_content_types": supported_types, + "browse_results_are_releases": source_results_are_releases(source_name), "modes": modes, } ) @@ -401,7 +415,7 @@ def register_request_routes( or data.get("content_type") or book_data.get("content_type") ) - request_level, release_data = _normalize_direct_request_payload( + request_level, release_data = _normalize_release_result_request_payload( source=source, request_level=request_level, book_data=book_data, diff --git a/shelfmark/core/search_plan.py b/shelfmark/core/search_plan.py index 98d29a7b..415ef8f5 100644 --- a/shelfmark/core/search_plan.py +++ b/shelfmark/core/search_plan.py @@ -6,6 +6,7 @@ from typing import List, Optional MANUAL_QUERY_MAX_LEN = 256 from shelfmark.core.config import config +from shelfmark.core.models import SearchFilters from shelfmark.metadata_providers import ( BookMetadata, group_languages_by_localized_title, @@ -37,6 +38,7 @@ class ReleaseSearchPlan: grouped_title_variants: List[ReleaseSearchVariant] manual_query: Optional[str] = None indexers: Optional[List[str]] = None # Indexer names for Prowlarr (overrides settings) + source_filters: Optional[SearchFilters] = None @property def primary_query(self) -> str: @@ -88,6 +90,7 @@ def build_release_search_plan( languages: Optional[List[str]] = None, manual_query: Optional[str] = None, indexers: Optional[List[str]] = None, + source_filters: Optional[SearchFilters] = None, ) -> ReleaseSearchPlan: resolved_languages = _normalize_languages(languages) @@ -109,6 +112,7 @@ def build_release_search_plan( grouped_title_variants=[variant], manual_query=resolved_manual_query, indexers=indexers, + source_filters=source_filters, ) isbn_candidates: List[str] = [] @@ -165,4 +169,5 @@ def build_release_search_plan( grouped_title_variants=grouped_variants, manual_query=None, indexers=indexers, + source_filters=source_filters, ) diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index 381b4718..7ec75769 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -16,15 +16,17 @@ from typing import Any, Dict, List, Optional, Tuple from shelfmark.core.config import config from shelfmark.core.logger import setup_logger -from shelfmark.core.models import BookInfo, DownloadTask, QueueStatus, SearchFilters, SearchMode +from shelfmark.core.models import DownloadTask, QueueStatus, SearchMode from shelfmark.core.queue import book_queue from shelfmark.core.utils import transform_cover_url, is_audiobook as check_audiobook from shelfmark.config import env as env_config from shelfmark.download.fs import run_blocking_io from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path from shelfmark.download.postprocess.router import post_process_download -from shelfmark.release_sources import direct_download, get_handler, get_source_display_name -from shelfmark.release_sources.direct_download import SearchUnavailable +from shelfmark.release_sources import ( + get_handler, + get_source_display_name, +) logger = setup_logger(__name__) @@ -57,26 +59,6 @@ _last_activity: Dict[str, float] = {} _last_status_event: Dict[str, Tuple[str, Optional[str]]] = {} STALL_TIMEOUT = 300 # 5 minutes without progress/status update = stalled -def search_books(query: str, filters: SearchFilters) -> List[Dict[str, Any]]: - """Search for books matching the query.""" - try: - books = direct_download.search_books(query, filters) - return [_book_info_to_dict(book) for book in books] - except SearchUnavailable: - raise - except Exception as e: - logger.error_trace(f"Error searching books: {e}") - raise - -def get_book_info(book_id: str) -> Optional[Dict[str, Any]]: - """Get detailed information for a specific book.""" - try: - book = direct_download.get_book_info(book_id) - return _book_info_to_dict(book) - except Exception as e: - logger.error_trace(f"Error getting book info: {e}") - raise - def _is_plain_email_address(value: str) -> bool: parsed = parseaddr(value or "")[1] return bool(parsed) and "@" in parsed and parsed == value @@ -97,76 +79,17 @@ def _resolve_email_destination( return None, "Configured email recipient is invalid" return None, None - - -def queue_book( - book_id: str, - priority: int = 0, - source: str = "direct_download", - user_id: Optional[int] = None, - username: Optional[str] = None, -) -> Tuple[bool, Optional[str]]: - """Add a book to the download queue. Returns (success, error_message).""" - try: - book_info = direct_download.get_book_info(book_id, fetch_download_count=False) - if not book_info: - error_msg = f"Could not fetch book info for {book_id}" - logger.warning(error_msg) - return False, error_msg - - books_output_mode = str( - config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder" - ).strip().lower() - is_audiobook = check_audiobook(book_info.content) - - # Capture output mode at queue time so tasks aren't affected if settings change later. - output_mode = "folder" if is_audiobook else books_output_mode - output_args: Dict[str, Any] = {} - - if output_mode == "email" and not is_audiobook: - email_to, email_error = _resolve_email_destination(user_id=user_id) - if email_error: - return False, email_error - if email_to: - output_args = {"to": email_to} - - # Create a source-agnostic download task - task = DownloadTask( - task_id=book_id, - source=source, - title=book_info.title, - author=book_info.author, - format=book_info.format, - size=book_info.size, - preview=book_info.preview, - content_type=book_info.content, - search_mode=SearchMode.DIRECT, - output_mode=output_mode, - output_args=output_args, - priority=priority, - user_id=user_id, - username=username, - ) - - if not book_queue.add(task): - logger.info(f"Book already in queue: {book_info.title}") - return False, "Book is already in the download queue" - - logger.info(f"Book queued with priority {priority}: {book_info.title}") - - # Broadcast status update via WebSocket - if ws_manager: - ws_manager.broadcast_status_update(queue_status()) - - return True, None - except SearchUnavailable as e: - error_msg = f"Search service unavailable: {e}" - logger.warning(error_msg) - return False, error_msg - except Exception as e: - error_msg = f"Error queueing book: {e}" - logger.error_trace(error_msg) - return False, error_msg +def _parse_release_search_mode(value: Any) -> SearchMode: + if isinstance(value, SearchMode): + return value + if value is None: + return SearchMode.UNIVERSAL + if isinstance(value, str): + try: + return SearchMode(value.strip().lower()) + except ValueError as exc: + raise ValueError(f"Invalid search_mode: {value}") from exc + raise ValueError(f"Invalid search_mode: {value}") def queue_release( @@ -177,12 +100,13 @@ def queue_release( ) -> Tuple[bool, Optional[str]]: """Add a release to the download queue. Returns (success, error_message).""" try: - source = release_data.get('source', 'direct_download') + source = release_data['source'] extra = release_data.get('extra', {}) raw_request_id = release_data.get('_request_id') request_id: Optional[int] = None if isinstance(raw_request_id, int) and raw_request_id > 0: request_id = raw_request_id + search_mode = _parse_release_search_mode(release_data.get("search_mode")) # Get author, year, preview, and content_type from top-level (preferred) or extra (fallback) author = release_data.get('author') or extra.get('author') @@ -235,7 +159,7 @@ def queue_release( series_name=series_name, series_position=series_position, subtitle=subtitle, - search_mode=SearchMode.UNIVERSAL, + search_mode=search_mode, output_mode=output_mode, output_args=output_args, priority=priority, @@ -257,8 +181,7 @@ def queue_release( return True, None except ValueError as e: - # Handler not found for this source - error_msg = f"Unknown release source: {e}" + error_msg = str(e) logger.warning(error_msg) return False, error_msg except KeyError as e: @@ -307,20 +230,6 @@ def get_book_data(task_id: str) -> Tuple[Optional[bytes], Optional[DownloadTask] task.download_path = None return None, task -def _book_info_to_dict(book: BookInfo) -> Dict[str, Any]: - """Convert BookInfo to dict, transforming cover URLs for caching.""" - result = { - key: value for key, value in book.__dict__.items() - if value is not None - } - - # Transform external preview URLs to local proxy URLs - if result.get('preview'): - result['preview'] = transform_cover_url(result['preview'], book.id) - - return result - - def _task_to_dict(task: DownloadTask) -> Dict[str, Any]: """Convert DownloadTask to dict for frontend, transforming cover URLs.""" # Transform external preview URLs to local proxy URLs diff --git a/shelfmark/download/postprocess/destination.py b/shelfmark/download/postprocess/destination.py index df1ccc2a..33be8a6d 100644 --- a/shelfmark/download/postprocess/destination.py +++ b/shelfmark/download/postprocess/destination.py @@ -6,12 +6,12 @@ from pathlib import Path from shelfmark.core.logger import setup_logger from shelfmark.core.models import DownloadTask from shelfmark.core.utils import ( - get_aa_content_type_dir, get_destination, is_audiobook as check_audiobook, ) from shelfmark.download.fs import run_blocking_io from shelfmark.download.permissions_debug import log_path_permission_context +from shelfmark.release_sources import get_source logger = setup_logger("shelfmark.download.postprocess.pipeline") @@ -63,9 +63,12 @@ def get_final_destination(task: DownloadTask) -> Path: is_audiobook = check_audiobook(task.content_type) - if task.source == "direct_download" and not is_audiobook: - override = get_aa_content_type_dir(task.content_type) - if override: - return override + try: + override = get_source(task.source).get_destination_override(task) + except ValueError: + override = None + + if override: + return override return get_destination(is_audiobook, user_id=task.user_id, username=task.username) diff --git a/shelfmark/main.py b/shelfmark/main.py index 1d880ec3..4e8c85b6 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -18,8 +18,7 @@ from werkzeug.security import check_password_hash from werkzeug.wrappers import Response from shelfmark.download import orchestrator as backend -from shelfmark.release_sources import get_source_display_name -from shelfmark.release_sources.direct_download import SearchUnavailable +from shelfmark.release_sources import SourceUnavailableError, get_source_display_name from shelfmark.config.settings import _SUPPORTED_BOOK_LANGUAGE from shelfmark.config.env import ( BUILD_VERSION, CONFIG_DIR, CWA_DB_PATH, DEBUG, HIDE_LOCAL_AUTH, @@ -810,120 +809,77 @@ if DEBUG: """ os._exit(0) -@app.route('/api/search', methods=['GET']) -@login_required -def api_search() -> Union[Response, Tuple[Response, int]]: - """ - Search for books matching the provided query. - - Query Parameters: - query (str): Search term (ISBN, title, author, etc.) - isbn (str): Book ISBN - author (str): Book Author - title (str): Book Title - lang (str): Book Language - sort (str): Order to sort results - content (str): Content type of book - format (str): File format filter (pdf, epub, mobi, azw3, fb2, djvu, cbz, cbr) - - Returns: - flask.Response: JSON array of matching books or error response. - """ - query = request.args.get('query', '') - - filters = SearchFilters( - isbn = request.args.getlist('isbn'), - author = request.args.getlist('author'), - title = request.args.getlist('title'), - lang = request.args.getlist('lang'), - sort = request.args.get('sort'), - content = request.args.getlist('content'), - format = request.args.getlist('format'), +def _parse_search_filters_from_request() -> SearchFilters: + """Parse direct/source browse filters from query parameters.""" + return SearchFilters( + isbn=request.args.getlist('isbn'), + author=request.args.getlist('author'), + title=request.args.getlist('title'), + lang=request.args.getlist('lang'), + sort=request.args.get('sort'), + content=request.args.getlist('content'), + format=request.args.getlist('format'), ) - if not query and not any(vars(filters).values()): - return jsonify([]) - try: - books = backend.search_books(query, filters) - return jsonify(books) - except SearchUnavailable as e: - logger.warning(f"Search unavailable: {e}") - return jsonify({"error": str(e)}), 503 - except Exception as e: - logger.error_trace(f"Search error: {e}") - return jsonify({"error": str(e)}), 500 +def _build_source_query_book(query_text: str, filters: SearchFilters): + """Build a synthetic book context for source-native browse searches.""" + from shelfmark.metadata_providers import BookMetadata -@app.route('/api/info', methods=['GET']) -@login_required -def api_info() -> Union[Response, Tuple[Response, int]]: - """ - Get detailed book information. + author_values = [value.strip() for value in (filters.author or []) if str(value).strip()] + title_values = [value.strip() for value in (filters.title or []) if str(value).strip()] + isbn_values = [value.strip() for value in (filters.isbn or []) if str(value).strip()] + title = ( + title_values[0] + if title_values + else query_text + or (isbn_values[0] if isbn_values else "") + or (author_values[0] if author_values else "Direct Search") + ) + author = author_values[0] if author_values else "" - Query Parameters: - id (str): Book identifier (MD5 hash) + return BookMetadata( + provider="manual", + provider_id=query_text or title, + provider_display_name="Manual Search", + title=title, + search_title=title, + search_author=author or None, + authors=author_values, + ) - Returns: - flask.Response: JSON object with book details, or an error message. - """ - book_id = request.args.get('id', '') - if not book_id: - return jsonify({"error": "No book ID provided"}), 400 - try: - book = backend.get_book_info(book_id) - if book: - return jsonify(book) - return jsonify({"error": "Book not found"}), 404 - except Exception as e: - logger.error_trace(f"Info error: {e}") - return jsonify({"error": str(e)}), 500 +def _serialize_browse_record(record) -> dict: + """Serialize a source-native browse record for the frontend.""" + result = { + key: value for key, value in record.__dict__.items() + if value is not None + } -@app.route('/api/download', methods=['GET']) -@login_required -def api_download() -> Union[Response, Tuple[Response, int]]: - """ - Queue a book for download. + preview = result.get("preview") + if isinstance(preview, str) and preview: + from shelfmark.core.utils import transform_cover_url - Query Parameters: - id (str): Book identifier (MD5 hash) + result["preview"] = transform_cover_url(preview, record.id) - Returns: - flask.Response: JSON status object indicating success or failure. - """ - book_id = request.args.get('id', '') - if not book_id: - return jsonify({"error": "No book ID provided"}), 400 + return result - try: - policy_mode = _resolve_policy_mode_for_current_user( - source="direct_download", - content_type="ebook", - ) - if policy_mode is not None and policy_mode != PolicyMode.DOWNLOAD: - return _policy_block_response(policy_mode) - priority = int(request.args.get('priority', 0)) - # Per-user download overrides - db_user_id = session.get('db_user_id') - _username = session.get('user_id') - db_user_id, _username, on_behalf_error = _resolve_download_user_context( - db_user_id, - _username, - request.args.get("on_behalf_of_user_id"), - ) - if on_behalf_error: - return on_behalf_error - success, error_msg = backend.queue_book( - book_id, priority, - user_id=db_user_id, username=_username, - ) - if success: - return jsonify({"status": "queued", "priority": priority}) - return jsonify({"error": error_msg or "Failed to queue book"}), 500 - except Exception as e: - logger.error_trace(f"Download error: {e}") - return jsonify({"error": str(e)}), 500 +def _serialize_release(release) -> dict: + """Serialize a release for the frontend, normalizing preview URLs.""" + from dataclasses import asdict + from shelfmark.core.utils import transform_cover_url + + result = asdict(release) + extra = result.get("extra") + if isinstance(extra, dict): + preview = extra.get("preview") + if isinstance(preview, str) and preview: + extra = dict(extra) + extra["preview"] = transform_cover_url(preview, release.source_id) + result["extra"] = extra + + return result @app.route('/api/releases/download', methods=['POST']) @@ -953,8 +909,10 @@ def api_download_release() -> Union[Response, Tuple[Response, int]]: if 'source_id' not in data: return jsonify({"error": "source_id is required"}), 400 + if 'source' not in data: + return jsonify({"error": "source is required"}), 400 - source = data.get('source', 'direct_download') + source = data['source'] resolved_content_type, inferred_content_type = _resolve_release_content_type(data, source) policy_mode = _resolve_policy_mode_for_current_user( source=source, @@ -2258,18 +2216,25 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: flask.Response: JSON with list of available releases. """ try: + from dataclasses import asdict from shelfmark.metadata_providers import ( BookMetadata, get_provider, is_provider_registered, get_provider_kwargs, ) - from shelfmark.release_sources import get_source, list_available_sources, serialize_column_config - from dataclasses import asdict - + from shelfmark.release_sources import ( + browse_record_to_book_metadata, + get_source, + list_available_sources, + serialize_column_config, + source_results_are_releases, + ) + from shelfmark.core.search_plan import build_release_search_plan provider = request.args.get('provider', '').strip() book_id = request.args.get('book_id', '').strip() source_filter = request.args.get('source', '').strip() + query_text = request.args.get('query', '').strip() # Accept title/author from frontend to avoid re-fetching metadata title_param = request.args.get('title', '').strip() author_param = request.args.get('author', '').strip() @@ -2285,47 +2250,33 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: # Accept indexer names for Prowlarr filtering (comma-separated) indexers_param = request.args.get('indexers', '').strip() indexers = [idx.strip() for idx in indexers_param.split(',') if idx.strip()] if indexers_param else None + browse_filters = _parse_search_filters_from_request() + has_browse_filters = bool(query_text or any(vars(browse_filters).values())) + + source_query_filters = None + is_source_provider = bool(provider) and source_results_are_releases(provider) if not provider or not book_id: - return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400 + if not source_filter or not has_browse_filters: + return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400 + if not source_results_are_releases(source_filter): + return jsonify({"error": f"Source does not support browse release search: {source_filter}"}), 400 - # Direct mode request approvals can open ReleaseModal with provider=direct_download. - # In that flow, treat the direct result as release-search context instead of requiring - # a metadata provider registration. - if provider == "direct_download": - direct_book = backend.get_book_info(book_id) - if not isinstance(direct_book, dict): - return jsonify({"error": "Book not found in direct source"}), 404 + book = _build_source_query_book(query_text, browse_filters) + source_query_filters = browse_filters + elif is_source_provider: + # Source-backed browse flows can reopen the release modal with provider=. + # In that flow, treat the source-native record as release-search context instead of + # requiring a metadata provider registration. + source = get_source(provider) + direct_record = source.get_record(book_id) + if direct_record is None: + return jsonify({"error": "Book not found in release source"}), 404 - resolved_title = title_param or str(direct_book.get("title") or "").strip() or "Unknown title" - resolved_author = author_param or str(direct_book.get("author") or "").strip() - authors = [part.strip() for part in resolved_author.split(",") if part.strip()] - if not authors and resolved_author: - authors = [resolved_author] - - raw_publish_year = direct_book.get("year") - publish_year = None - if isinstance(raw_publish_year, int): - publish_year = raw_publish_year - elif isinstance(raw_publish_year, str): - normalized_year = raw_publish_year.strip() - if normalized_year.isdigit(): - publish_year = int(normalized_year) - - book = BookMetadata( - provider="direct_download", - provider_id=book_id, - provider_display_name="Direct Download", - title=resolved_title, - search_title=resolved_title, - search_author=resolved_author or None, - authors=authors, - cover_url=direct_book.get("preview"), - description=direct_book.get("description"), - publisher=direct_book.get("publisher"), - publish_year=publish_year, - language=direct_book.get("language"), - source_url=direct_book.get("source_url"), + book = browse_record_to_book_metadata( + direct_record, + title_override=title_param or None, + author_override=author_param or None, ) elif provider == "manual": resolved_title = title_param or manual_query or "Manual Search" @@ -2361,12 +2312,13 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: book.title = title_param # Determine which release sources to search - if source_filter: + if source_query_filters is not None: sources_to_search = [source_filter] - elif provider == "direct_download": - # Direct mode has no metadata-provider fanout; keep release browsing focused - # on Direct Download results (same dataset as legacy direct search). - sources_to_search = ["direct_download"] + elif source_filter: + sources_to_search = [source_filter] + elif is_source_provider: + # Source-backed browse flows stay within the source that produced the record. + sources_to_search = [provider] else: # Search only enabled sources sources_to_search = [src["name"] for src in list_available_sources() if src["enabled"]] @@ -2381,11 +2333,18 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: source = get_source(source_name) source_instances[source_name] = source - from shelfmark.core.search_plan import build_release_search_plan + plan = build_release_search_plan( + book, + languages=browse_filters.lang if source_query_filters is not None else languages, + manual_query=query_text if source_query_filters is not None else manual_query, + indexers=indexers, + source_filters=source_query_filters, + ) - plan = build_release_search_plan(book, languages=languages, manual_query=manual_query, indexers=indexers) - - if plan.manual_query: + if plan.source_filters is not None: + planned_query = plan.manual_query or plan.primary_query + planned_query_type = "query" + elif plan.manual_query: planned_query = plan.manual_query planned_query_type = "manual" elif not expand_search and plan.isbn_candidates: @@ -2409,7 +2368,7 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: errors.append(f"{source_name}: {str(e)}") # Convert Release objects to dicts - releases_data = [asdict(release) for release in all_releases] + releases_data = [_serialize_release(release) for release in all_releases] # Get column config from the first source searched # Reuse the same instance to get any dynamic data (e.g., online_servers for IRC) @@ -2446,8 +2405,9 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: if errors: response["errors"] = errors - # If no releases found and there were errors, return 503 with error message - # This matches the behavior of /api/search when Anna's Archive is unreachable + # If no releases found and there were errors, return 503 with the first + # source failure message so direct-mode source query searches surface the + # same unavailable-state messaging as release modal searches. if not releases_data and errors: # Use the first error message (typically the most relevant) error_message = errors[0] @@ -2457,6 +2417,9 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": error_message}), 503 return jsonify(response) + except SourceUnavailableError as e: + logger.warning(f"Release search unavailable: {e}") + return jsonify({"error": str(e)}), 503 except Exception as e: logger.error_trace(f"Releases search error: {e}") return jsonify({"error": str(e)}), 500 @@ -2480,6 +2443,28 @@ def api_release_sources() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": str(e)}), 500 +@app.route('/api/release-sources//records/', methods=['GET']) +@login_required +def api_release_source_record(source_name: str, record_id: str) -> Union[Response, Tuple[Response, int]]: + """Resolve a source-native browse record for a release source.""" + try: + from shelfmark.release_sources import get_source + + source = get_source(source_name) + record = source.get_record(record_id) + if record is None: + return jsonify({"error": "Record not found"}), 404 + return jsonify(_serialize_browse_record(record)) + except ValueError: + return jsonify({"error": f"Unknown release source: {source_name}"}), 400 + except SourceUnavailableError as e: + logger.warning(f"Release source record unavailable: {e}") + return jsonify({"error": str(e)}), 503 + except Exception as e: + logger.error_trace(f"Release source record error: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route('/api/settings', methods=['GET']) @login_required def api_settings_get_all() -> Union[Response, Tuple[Response, int]]: diff --git a/shelfmark/release_sources/__init__.py b/shelfmark/release_sources/__init__.py index aea1e4e9..c424ebbb 100644 --- a/shelfmark/release_sources/__init__.py +++ b/shelfmark/release_sources/__init__.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field, asdict from enum import Enum +from pathlib import Path from threading import Event from typing import List, Optional, Dict, Type, Callable, Literal, Any, TYPE_CHECKING @@ -21,6 +22,35 @@ class ReleaseProtocol(str, Enum): DCC = "dcc" # IRC DCC +class SourceUnavailableError(Exception): + """Raised when a source is configured but currently unreachable.""" + + +@dataclass +class BrowseRecord: + """Source-native browse/search record used before normalization to Release.""" + id: str + title: str + source: str + preview: Optional[str] = None + author: Optional[str] = None + publisher: Optional[str] = None + year: Optional[str] = None + language: Optional[str] = None + content: Optional[str] = None + format: Optional[str] = None + size: Optional[str] = None + info: Optional[Dict[str, List[str]]] = None + description: Optional[str] = None + download_urls: List[str] = field(default_factory=list) + download_path: Optional[str] = None + priority: int = 0 + progress: Optional[float] = None + status_message: Optional[str] = None + added_time: Optional[float] = None + source_url: Optional[str] = None + + @dataclass class Release: """A downloadable release - all sources return this same structure.""" @@ -281,6 +311,23 @@ class ReleaseSource(ABC): """Get column configuration for release list UI. Override for custom columns.""" return _default_column_config() + def get_record( + self, + record_id: str, + *, + fetch_download_count: bool = True, + ) -> Optional[BrowseRecord]: + """Resolve a source-native record for browse flows.""" + raise NotImplementedError(f"{self.display_name} does not support record lookup") + + def search_results_are_releases(self) -> bool: + """Whether source-native browse results already represent concrete releases.""" + return False + + def get_destination_override(self, task: DownloadTask) -> Optional[Path]: + """Return a source-specific destination override for a queued download.""" + return None + class DownloadHandler(ABC): """Interface for executing downloads. @@ -364,6 +411,7 @@ def list_available_sources() -> List[dict]: "display_name": instance.display_name, "enabled": instance.is_available(), "supported_content_types": getattr(instance, 'supported_content_types', ["ebook", "audiobook"]), + "browse_results_are_releases": instance.search_results_are_releases(), "can_be_default": getattr(instance, 'can_be_default', True), }) return result @@ -376,6 +424,49 @@ def get_source_display_name(name: str) -> str: return name.replace('_', ' ').title() +def browse_record_to_book_metadata( + record: BrowseRecord, + *, + title_override: Optional[str] = None, + author_override: Optional[str] = None, +) -> BookMetadata: + """Convert a source-native browse record into generic book metadata.""" + resolved_title = title_override or str(record.title or "").strip() or "Unknown title" + resolved_author = author_override or str(record.author or "").strip() + authors = [part.strip() for part in resolved_author.split(",") if part.strip()] + publish_year = None + + if isinstance(record.year, int): + publish_year = record.year + elif isinstance(record.year, str): + normalized_year = record.year.strip() + if normalized_year.isdigit(): + publish_year = int(normalized_year) + + return BookMetadata( + provider=record.source, + provider_id=record.id, + provider_display_name=get_source_display_name(record.source), + title=resolved_title, + search_title=resolved_title, + search_author=resolved_author or None, + authors=authors, + cover_url=record.preview, + description=record.description, + publisher=record.publisher, + publish_year=publish_year, + language=record.language, + source_url=record.source_url, + ) + + +def source_results_are_releases(name: str) -> bool: + """Whether a source's browse/search results already map to concrete releases.""" + if name not in _SOURCES: + return False + return _SOURCES[name]().search_results_are_releases() + + # Import source implementations to trigger registration # These must be imported AFTER the base classes and registry are defined from shelfmark.release_sources import direct_download # noqa: F401, E402 diff --git a/shelfmark/release_sources/direct_download.py b/shelfmark/release_sources/direct_download.py index 299dabd4..14050f54 100644 --- a/shelfmark/release_sources/direct_download.py +++ b/shelfmark/release_sources/direct_download.py @@ -17,15 +17,17 @@ from shelfmark.download import http as downloader from shelfmark.download import network from shelfmark.config.env import DEBUG_SKIP_SOURCES, TMP_DIR from shelfmark.core.config import config -from shelfmark.core.utils import CONTENT_TYPES +from shelfmark.core.utils import CONTENT_TYPES, get_aa_content_type_dir, is_audiobook as check_audiobook from shelfmark.core.logger import setup_logger -from shelfmark.core.models import BookInfo, SearchFilters, DownloadTask +from shelfmark.core.models import SearchFilters, DownloadTask, build_filename from shelfmark.metadata_providers import BookMetadata, group_languages_by_localized_title from shelfmark.release_sources import ( + BrowseRecord, Release, ReleaseProtocol, ReleaseSource, DownloadHandler, + SourceUnavailableError, register_source, register_handler, ReleaseColumnConfig, @@ -136,11 +138,11 @@ def _normalize_size(size_str: str) -> str: return _SIZE_UNIT_PATTERN.sub(lambda m: m.group(1).upper(), size_str.strip()) -class SearchUnavailable(Exception): +class SearchUnavailable(SourceUnavailableError): """Raised when Anna's Archive cannot be reached via any mirror/DNS.""" -def search_books(query: str, filters: SearchFilters) -> List[BookInfo]: +def search_books(query: str, filters: SearchFilters) -> List[BrowseRecord]: """Search for books matching the query. Args: @@ -148,7 +150,7 @@ def search_books(query: str, filters: SearchFilters) -> List[BookInfo]: filters: Search filters (language, format, content type, etc.) Returns: - List[BookInfo]: List of matching books + List[BrowseRecord]: List of matching books Raises: SearchUnavailable: If Anna's Archive cannot be reached @@ -232,7 +234,7 @@ def search_books(query: str, filters: SearchFilters) -> List[BookInfo]: return books -def get_book_info(book_id: str, fetch_download_count: bool = True) -> BookInfo: +def get_book_info(book_id: str, fetch_download_count: bool = True) -> BrowseRecord: """Get detailed information for a specific book. Args: @@ -241,22 +243,22 @@ def get_book_info(book_id: str, fetch_download_count: bool = True) -> BookInfo: Only needed for display in DetailsModal, not for downloads. Returns: - BookInfo: Detailed book information including download URLs + BrowseRecord: Detailed book information including download URLs """ url = f"{network.get_aa_base_url()}/md5/{book_id}" selector = network.AAMirrorSelector() html = downloader.html_get_page(url, selector=selector, allow_bypasser_fallback=False) if not html: - raise Exception(f"Failed to fetch book info for ID: {book_id}") + raise SearchUnavailable("Unable to reach download source. Network restricted or mirrors are blocked.") soup = BeautifulSoup(html, "html.parser") return _parse_book_info_page(soup, book_id, fetch_download_count) -def _parse_search_result_row(row: Tag) -> Optional[BookInfo]: - """Parse a single search result row into a BookInfo object.""" +def _parse_search_result_row(row: Tag) -> Optional[BrowseRecord]: + """Parse a single search result row into a browse record.""" try: if row.text.strip().lower().startswith("your ad here"): return None @@ -264,10 +266,11 @@ def _parse_search_result_row(row: Tag) -> Optional[BookInfo]: preview_img = cells[0].find("img") preview = preview_img["src"] if preview_img else None - return BookInfo( + return BrowseRecord( id=row.find_all("a")[0]["href"].split("/")[-1], - preview=preview, title=cells[1].find("span").next, + source="direct_download", + preview=preview, author=cells[2].find("span").next, publisher=cells[3].find("span").next, year=cells[4].find("span").next, @@ -281,8 +284,8 @@ def _parse_search_result_row(row: Tag) -> Optional[BookInfo]: return None -def _parse_book_info_page(soup: BeautifulSoup, book_id: str, fetch_download_count: bool = True) -> BookInfo: - """Parse the book info page HTML into a BookInfo object.""" +def _parse_book_info_page(soup: BeautifulSoup, book_id: str, fetch_download_count: bool = True) -> BrowseRecord: + """Parse the book info page HTML into a browse record.""" data = soup.select_one("body > main > div:nth-of-type(1)") if not data: @@ -379,10 +382,11 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str, fetch_download_coun # Extract basic information description = _extract_book_description(soup) - book_info = BookInfo( + book_info = BrowseRecord( id=book_id, - preview=preview, title=book_title, + source="direct_download", + preview=preview, content=content, publisher=(_find_in_divs(divs, "icon-[mdi--company]", is_class=True) or [""])[0], author=(_find_in_divs(divs, "icon-[mdi--user-edit]", is_class=True) or [""])[0], @@ -538,7 +542,7 @@ def _group_urls_by_source(urls: List[str], urls_by_source: Dict[str, List[str]]) urls_by_source.setdefault(source_type, []).append(url) -def _fetch_aa_page_urls(book_info: BookInfo, urls_by_source: Dict[str, List[str]]) -> None: +def _fetch_aa_page_urls(book_info: BrowseRecord, urls_by_source: Dict[str, List[str]]) -> None: """Fetch and parse AA page, populating urls_by_source dict. Groups existing book_info.download_urls by source type. If book_info @@ -557,7 +561,7 @@ def _fetch_aa_page_urls(book_info: BookInfo, urls_by_source: Dict[str, List[str] def _get_urls_for_source( source_id: str, - book_info: BookInfo, + book_info: BrowseRecord, selector: network.AAMirrorSelector, cancel_flag: Optional[Event], status_callback: Optional[Callable[[str, Optional[str]], None]], @@ -608,7 +612,7 @@ def _get_urls_for_source( def _try_download_url( url: str, source_id: str, - book_info: BookInfo, + book_info: BrowseRecord, book_path: Path, progress_callback: Optional[Callable[[float], None]], cancel_flag: Optional[Event], @@ -747,7 +751,7 @@ def _extract_libgen_download_url(link: str, cancel_flag: Optional[Event] = None) def _download_book( - book_info: BookInfo, + book_info: BrowseRecord, book_path: Path, progress_callback: Optional[Callable[[float], None]] = None, cancel_flag: Optional[Event] = None, @@ -1046,33 +1050,32 @@ def _extract_countdown_seconds(soup: BeautifulSoup, html_str: str) -> int: return 0 -def _book_info_to_release(book_info: BookInfo) -> Release: - """Convert a BookInfo object to a Release object. +def _browse_record_to_release(record: BrowseRecord) -> Release: + """Convert a browse record to a Release object. - This bridges the existing BookInfo model (which combines metadata + release info) - to the new Release model (release info only). + This bridges the direct source's browse data to the generic release model. """ return Release( - source="direct_download", - source_id=book_info.id, - title=book_info.title, - format=book_info.format, - language=book_info.language, # Top-level language for filtering - size=book_info.size, - download_url=book_info.download_urls[0] if book_info.download_urls else None, - info_url=f"{network.get_aa_base_url()}/md5/{book_info.id}", + 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.download_urls[0] if record.download_urls else None, + info_url=f"{network.get_aa_base_url()}/md5/{record.id}", protocol=ReleaseProtocol.HTTP, indexer="Direct Download", - content_type=book_info.content, # Preserve content type from source + content_type=record.content, # Preserve content type from source extra={ - "author": book_info.author, - "publisher": book_info.publisher, - "year": book_info.year, - "language": book_info.language, - "preview": book_info.preview, - "description": book_info.description, - "download_urls": book_info.download_urls, - "info": book_info.info, + "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, } ) @@ -1140,6 +1143,25 @@ class DirectDownloadSource(ReleaseSource): supported_filters=["format", "language"], # AA has reliable language metadata ) + def get_record( + self, + record_id: str, + *, + fetch_download_count: bool = True, + ) -> Optional[BrowseRecord]: + """Resolve a direct-download record for direct-mode info/download flows.""" + return get_book_info(record_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) -> Optional[Path]: + """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, @@ -1164,6 +1186,15 @@ class DirectDownloadSource(ReleaseSource): # Reset search type tracking self._last_search_type = "title_author" + if plan.source_filters is not None: + query = plan.manual_query or "" + logger.debug(f"Searching direct_download: source_query='{query}', langs={lang_filter}") + filters = plan.source_filters or SearchFilters() + filters.lang = lang_filter if lang_filter is not None else (filters.lang or []) + results = search_books(query, filters) + self._last_search_type = "manual" if query else "title_author" + return [_browse_record_to_release(record) for record in results] + # ISBN search first (unless expand_search requested) if plan.manual_query: expand_search = True @@ -1179,7 +1210,7 @@ class DirectDownloadSource(ReleaseSource): if results: logger.info(f"Found {len(results)} releases via ISBN") self._last_search_type = "isbn" - return [_book_info_to_release(bi) for bi in results] + return [_browse_record_to_release(record) for record in results] logger.debug("No ISBN results, falling back to title+author") except SearchUnavailable: raise @@ -1192,7 +1223,7 @@ class DirectDownloadSource(ReleaseSource): # Execute searches with deduplication seen_ids: set = set() - all_results: List[BookInfo] = [] + all_results: List[BrowseRecord] = [] for title, langs in searches: query = f"{title} {author}".strip() @@ -1212,7 +1243,7 @@ class DirectDownloadSource(ReleaseSource): logger.error(f"Search error: {e}") logger.info(f"Found {len(all_results)} releases via title+author") - return [_book_info_to_release(bi) for bi in all_results] + return [_browse_record_to_release(record) for record in all_results] def is_available(self) -> bool: """Direct download is always available.""" @@ -1258,13 +1289,15 @@ class DirectDownloadHandler(DownloadHandler): status_callback("cancelled", "Cancelled") return None - # Create BookInfo from task data - NO AA page fetch here + # Create browse record from task data - NO AA page fetch here # AA page is fetched lazily by _fetch_aa_page_urls only when # we actually reach an AA slow source in the priority order - book_info = BookInfo( + book_info = BrowseRecord( id=task.task_id, title=task.title, + source="direct_download", author=task.author, + year=task.year, format=task.format, size=task.size, preview=task.preview, @@ -1288,13 +1321,13 @@ class DirectDownloadHandler(DownloadHandler): def _execute_download( self, - book_info: BookInfo, + book_info: BrowseRecord, cancel_flag: Event, progress_callback: Callable[[float], None], status_callback: Callable[[str, Optional[str]], None] ) -> Optional[str]: """ - Internal method to execute the download with fetched BookInfo. + Internal method to execute the download with fetched browse record. This contains the core download logic: cascade through sources, handle bypass, move to final location. @@ -1308,7 +1341,12 @@ class DirectDownloadHandler(DownloadHandler): if file_org == "none": book_name = f"{book_info.id}.{book_info.format or 'bin'}" else: - book_name = book_info.get_filename() + book_name = build_filename( + book_info.title, + book_info.author, + book_info.year, + book_info.format, + ) book_path = TMP_DIR / book_name # Check cancellation before download diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index a06b67af..a68c9716 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -14,9 +14,8 @@ import { isMetadataBook, } from './types'; import { - getBookInfo, + getSourceRecordInfo, getMetadataBookInfo, - downloadBook, downloadRelease, cancelDownload, retryDownload, @@ -60,8 +59,10 @@ import { } from './utils/requestPolicyUi'; import { buildDirectRequestPayload, + buildReleaseDataFromDirectBook, buildMetadataBookRequestData, buildReleaseDataFromMetadataRelease, + getBrowseSource, getRequestSuccessMessage, toContentType, } from './utils/requestPayload'; @@ -751,7 +752,8 @@ function App() { // Show book details const handleShowDetails = async (id: string): Promise => { - const metadataBook = books.find(b => b.id === id && b.provider && b.provider_id); + const book = books.find((entry) => entry.id === id); + const metadataBook = book && isMetadataBook(book) ? book : null; if (metadataBook) { try { @@ -769,11 +771,18 @@ function App() { } } else { try { - const book = await getBookInfo(id); - setSelectedBook(book); + if (!book?.source) { + throw new Error('Book is missing source context'); + } + const fullBook = await getSourceRecordInfo(book.source, id); + setSelectedBook(fullBook); } catch (error) { - console.error('Failed to load book details:', error); - showToast('Failed to load book details', 'error'); + console.error('Failed to load book details, using search data:', error); + if (book) { + setSelectedBook(book); + } else { + showToast('Failed to load book details', 'error'); + } } } }; @@ -813,8 +822,8 @@ function App() { [submitRequest] ); - const getDirectPolicyMode = useCallback((): RequestPolicyMode => { - return getSourceMode('direct_download', 'ebook'); + const getDirectPolicyMode = useCallback((book: Book): RequestPolicyMode => { + return getSourceMode(getBrowseSource(book), 'ebook'); }, [getSourceMode]); const getUniversalDefaultPolicyMode = useCallback((): RequestPolicyMode => { @@ -853,8 +862,10 @@ function App() { const executeBookDownload = useCallback( async (book: Book, onBehalfOfUserId?: number): Promise => { + const source = getBrowseSource(book); + const directContentType: ContentType = 'ebook'; try { - await downloadBook(book.id, onBehalfOfUserId); + await downloadRelease(buildReleaseDataFromDirectBook(book), onBehalfOfUserId); await fetchStatus(); } catch (error) { console.error('Download failed:', error); @@ -862,6 +873,8 @@ function App() { const requiredMode = getPolicyGuardRequiredMode(error); policyTrace('direct.action:policy_guard', { bookId: book.id, + source, + contentType: directContentType, requiredMode, code: isApiResponseError(error) ? error.code : null, }); @@ -913,7 +926,7 @@ function App() { book_data: buildMetadataBookRequestData(book, normalizedContentType), release_data: buildReleaseDataFromMetadataRelease(book, release, normalizedContentType), context: { - source: release.source || 'direct_download', + source: release.source, content_type: normalizedContentType, request_level: 'release', }, @@ -927,7 +940,7 @@ function App() { book_data: buildMetadataBookRequestData(book, normalizedContentType), release_data: null, context: { - source: release.source || 'direct_download', + source: release.source, content_type: normalizedContentType, request_level: 'book', }, @@ -972,19 +985,24 @@ function App() { // Direct-mode action (download or release-level request based on policy). const handleDownload = async (book: Book): Promise => { - let mode = getDirectPolicyMode(); + const source = getBrowseSource(book); + const directContentType: ContentType = 'ebook'; + let mode = getDirectPolicyMode(book); policyTrace('direct.action:start', { bookId: book.id, - contentType: 'ebook', + source, + contentType: directContentType, cachedMode: mode, isAdmin: requestRoleIsAdmin, }); try { const latestPolicy = await refreshRequestPolicy({ force: true }); const effectiveIsAdmin = latestPolicy ? Boolean(latestPolicy.is_admin) : requestRoleIsAdmin; - mode = resolveSourceModeFromPolicy(latestPolicy, effectiveIsAdmin, 'direct_download', 'ebook'); + mode = resolveSourceModeFromPolicy(latestPolicy, effectiveIsAdmin, source, directContentType); policyTrace('direct.action:resolved', { bookId: book.id, + source, + contentType: directContentType, resolvedMode: mode, effectiveIsAdmin, defaults: latestPolicy?.defaults ?? null, @@ -994,6 +1012,8 @@ function App() { console.warn('Failed to refresh request policy before direct action:', error); policyTrace('direct.action:refresh_failed', { bookId: book.id, + source, + contentType: directContentType, mode, message: error instanceof Error ? error.message : String(error), }); @@ -1164,7 +1184,7 @@ function App() { book_data: buildMetadataBookRequestData(book, normalizedContentType), release_data: buildReleaseDataFromMetadataRelease(book, release, normalizedContentType), context: { - source: release.source || 'direct_download', + source: release.source, content_type: normalizedContentType, request_level: 'release', }, @@ -1300,16 +1320,20 @@ function App() { const getDirectActionButtonState = useCallback( (bookId: string): ButtonStateInfo => { const baseState = getButtonState(bookId); + const book = books.find((entry) => entry.id === bookId); + if (!book) { + return baseState; + } if (baseState.state === 'complete' && isDownloadTaskDismissed(bookId)) { return applyDirectPolicyModeToButtonState( { text: 'Download', state: 'download' }, - getDirectPolicyMode() + getDirectPolicyMode(book) ); } - const mode = getDirectPolicyMode(); + const mode = getDirectPolicyMode(book); return applyDirectPolicyModeToButtonState(baseState, mode); }, - [getButtonState, getDirectPolicyMode, isDownloadTaskDismissed] + [books, getButtonState, getDirectPolicyMode, isDownloadTaskDismissed] ); const getUniversalActionButtonState = useCallback( diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index 58e8d2a3..8784ec12 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -534,13 +534,13 @@ export const ReleaseModal = ({ const effectiveFormats = contentType === 'audiobook' && supportedAudiobookFormats.length > 0 ? supportedAudiobookFormats : supportedFormats; - const isDirectProviderContext = (book?.provider || '').toLowerCase() === 'direct_download'; const [isClosing, setIsClosing] = useState(false); const [isRequestingBook, setIsRequestingBook] = useState(false); // Available sources from plugin registry const [availableSources, setAvailableSources] = useState([]); const [sourcesLoading, setSourcesLoading] = useState(true); + const [sourcesError, setSourcesError] = useState(null); // Active tab (source name) const [activeTab, setActiveTab] = useState(''); @@ -767,24 +767,22 @@ export const ReleaseModal = ({ const fetchSources = async () => { try { setSourcesLoading(true); + setSourcesError(null); const sources = await getReleaseSources(); - const modalSources = isDirectProviderContext - ? sources.filter((source) => source.name === 'direct_download') - : sources; - setAvailableSources(modalSources); + setAvailableSources(sources); + + const providerContextSource = sources.find((source) => ( + source.name === book.provider && source.browse_results_are_releases + )); // Filter sources by content type support - const supportedSources = modalSources.filter(s => { + const supportedSources = sources.filter(s => { const types = s.supported_content_types || ['ebook', 'audiobook']; return types.includes(contentType); }); - if (isDirectProviderContext) { - if (supportedSources.some((source) => source.name === 'direct_download')) { - setActiveTab('direct_download'); - } else { - setActiveTab(''); - } + if (providerContextSource) { + setActiveTab(providerContextSource.name); return; } @@ -806,26 +804,21 @@ export const ReleaseModal = ({ } else if (sources.length > 0) { // No sources support this content type - fall back to first source setActiveTab(sources[0].name); + } else { + setActiveTab(''); } } catch (err) { console.error('Failed to fetch release sources:', err); - // Fallback: assume direct_download is available (for ebooks) - setAvailableSources([{ - name: 'direct_download', - display_name: "Direct Download", - enabled: true, - supported_content_types: ['ebook'] - }]); - if (contentType === 'ebook') { - setActiveTab('direct_download'); - } + setAvailableSources([]); + setActiveTab(''); + setSourcesError(err instanceof Error ? err.message : 'Failed to load release sources'); } finally { setSourcesLoading(false); } }; fetchSources(); - }, [book, defaultReleaseSource, contentType, isDirectProviderContext]); + }, [book, defaultReleaseSource, contentType]); // Fetch releases when active tab changes (with caching) // Initial fetch always uses ISBN-first search; expansion is handled by handleExpandSearch @@ -923,12 +916,21 @@ export const ReleaseModal = ({ type TabInfo = { name: string; displayName: string; enabled: boolean }; const enabledTabs: TabInfo[] = []; + const providerContextSourceName = ( + availableSources.find((source) => ( + source.name === book?.provider && source.browse_results_are_releases + ))?.name || null + ); // Filter to only enabled sources that support this content type availableSources.forEach((src) => { - const allowDisabledDirectTab = isDirectProviderContext && src.name === 'direct_download'; - // Skip disabled sources entirely, except direct tab in direct-provider context. - if (!src.enabled && !allowDisabledDirectTab) { + if (providerContextSourceName && src.name !== providerContextSourceName) { + return; + } + + const allowDisabledProviderContextTab = providerContextSourceName === src.name; + // Skip disabled sources entirely, except the source that owns the current browse record. + if (!src.enabled && !allowDisabledProviderContextTab) { return; } @@ -951,7 +953,7 @@ export const ReleaseModal = ({ } return enabledTabs; - }, [availableSources, defaultReleaseSource, contentType, isDirectProviderContext]); + }, [availableSources, book?.provider, defaultReleaseSource, contentType]); // Update tab indicator position when active tab changes useEffect(() => { @@ -1273,7 +1275,10 @@ export const ReleaseModal = ({ const currentTabLoading = loadingBySource[activeTab] ?? false; const currentTabError = errorBySource[activeTab] ?? null; - const isInitialLoading = currentTabLoading || (releasesBySource[activeTab] === undefined && !currentTabError); + const hasActiveTab = activeTab.length > 0; + const isInitialLoading = hasActiveTab && ( + currentTabLoading || (releasesBySource[activeTab] === undefined && !currentTabError) + ); const modal = (
+ ) : allTabs.length === 0 ? ( +
+ {sourcesError || 'No release sources are available for this book.'} +
) : (
{/* Tabs - scrollable on narrow screens */} @@ -1768,8 +1777,9 @@ export const ReleaseModal = ({ placeholder="All Indexers" /> )} - {/* Apply button - re-fetch with server-side filters/expansion (e.g. language-aware searches) */} - {(activeTab === 'direct_download' || activeTab === 'prowlarr') && ( + {/* Apply button - re-fetch when the source supports server-side filters */} + {(columnConfig.supported_filters?.includes('language') || + columnConfig.supported_filters?.includes('indexer')) && (