From 7fdf55f5fddbd2f367beea1d40c6c0f9f8384475 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:52:46 +0000 Subject: [PATCH] Enhancements to ABB handling (#621) - Migrate download client handling from /prowlarr to /download. Moves all torrent/usenet handling to app-level and gives ABB this functionality. - ABB Scraper now uses shared HTTP infrastructure instead of raw requests, adding retry and proxy support - Added author, age and bitrate info to ABB search results - Added "best match" sorting option for releases - Added size and bitrate sorting options for ABB - Removed bundled default ABB hostname, must be configured by the user - Added URL normalisation for ABB hostname - Rearranged settings UI, moved download clients to its own section. - More tests --- .../prowlarr => download}/clients/__init__.py | 14 +- shelfmark/download/clients/base_handler.py | 753 ++++++++++++++++++ .../prowlarr => download}/clients/deluge.py | 4 +- .../prowlarr => download}/clients/nzbget.py | 2 +- .../clients/qbittorrent.py | 4 +- .../prowlarr => download}/clients/rtorrent.py | 4 +- .../prowlarr => download}/clients/sabnzbd.py | 3 +- shelfmark/download/clients/settings.py | 657 +++++++++++++++ .../clients/torrent_utils.py | 1 + .../clients/transmission.py | 4 +- shelfmark/download/http.py | 35 +- .../release_sources/audiobookbay/handler.py | 185 +---- .../release_sources/audiobookbay/scraper.py | 152 ++-- .../release_sources/audiobookbay/settings.py | 44 +- .../release_sources/audiobookbay/source.py | 106 ++- .../release_sources/audiobookbay/utils.py | 50 +- .../release_sources/prowlarr/__init__.py | 11 +- shelfmark/release_sources/prowlarr/handler.py | 747 ++--------------- .../release_sources/prowlarr/settings.py | 673 +--------------- src/frontend/src/components/ReleaseCell.tsx | 15 + src/frontend/src/components/ReleaseModal.tsx | 184 ++++- .../components/settings/SettingsSidebar.tsx | 1 + src/frontend/src/types/index.ts | 3 + src/frontend/src/utils/bookTransformers.ts | 5 + tests/audiobookbay/test_handler.py | 123 +-- tests/audiobookbay/test_scraper.py | 224 +++--- tests/audiobookbay/test_source.py | 161 +++- tests/audiobookbay/test_utils.py | 37 +- tests/prowlarr/test_bencode.py | 2 +- tests/prowlarr/test_clients.py | 2 +- tests/prowlarr/test_failure_scenarios.py | 2 +- tests/prowlarr/test_handler.py | 2 +- tests/prowlarr/test_integration_clients.py | 18 +- tests/prowlarr/test_integration_failures.py | 8 +- tests/prowlarr/test_integration_handler.py | 2 +- tests/prowlarr/test_nzbget_client.py | 90 +-- tests/prowlarr/test_qbittorrent_client.py | 118 +-- tests/prowlarr/test_remote_path_mappings.py | 2 +- tests/prowlarr/test_rtorrent_client.py | 140 ++-- tests/prowlarr/test_sabnzbd_client.py | 94 +-- tests/prowlarr/test_torrent_utils.py | 2 +- tests/prowlarr/test_transmission_client.py | 126 +-- 42 files changed, 2640 insertions(+), 2170 deletions(-) rename shelfmark/{release_sources/prowlarr => download}/clients/__init__.py (95%) create mode 100644 shelfmark/download/clients/base_handler.py rename shelfmark/{release_sources/prowlarr => download}/clients/deluge.py (99%) rename shelfmark/{release_sources/prowlarr => download}/clients/nzbget.py (99%) rename shelfmark/{release_sources/prowlarr => download}/clients/qbittorrent.py (99%) rename shelfmark/{release_sources/prowlarr => download}/clients/rtorrent.py (98%) rename shelfmark/{release_sources/prowlarr => download}/clients/sabnzbd.py (99%) create mode 100644 shelfmark/download/clients/settings.py rename shelfmark/{release_sources/prowlarr => download}/clients/torrent_utils.py (99%) rename shelfmark/{release_sources/prowlarr => download}/clients/transmission.py (98%) diff --git a/shelfmark/release_sources/prowlarr/clients/__init__.py b/shelfmark/download/clients/__init__.py similarity index 95% rename from shelfmark/release_sources/prowlarr/clients/__init__.py rename to shelfmark/download/clients/__init__.py index a6a704dc..3373ec06 100644 --- a/shelfmark/release_sources/prowlarr/clients/__init__.py +++ b/shelfmark/download/clients/__init__.py @@ -1,5 +1,5 @@ """ -Download client infrastructure for Prowlarr integration. +Shared download client infrastructure for external release sources. This module provides: - DownloadState: Enum of valid download states @@ -423,9 +423,9 @@ def get_all_clients() -> Dict[str, List[Type[DownloadClient]]]: # Import client implementations to trigger registration # These imports are at the bottom to avoid circular imports -from shelfmark.release_sources.prowlarr.clients import qbittorrent # noqa: F401, E402 -from shelfmark.release_sources.prowlarr.clients import nzbget # noqa: F401, E402 -from shelfmark.release_sources.prowlarr.clients import sabnzbd # noqa: F401, E402 -from shelfmark.release_sources.prowlarr.clients import transmission # noqa: F401, E402 -from shelfmark.release_sources.prowlarr.clients import deluge # noqa: F401, E402 -from shelfmark.release_sources.prowlarr.clients import rtorrent # noqa: F401, E402 +from shelfmark.download.clients import qbittorrent # noqa: F401, E402 +from shelfmark.download.clients import nzbget # noqa: F401, E402 +from shelfmark.download.clients import sabnzbd # noqa: F401, E402 +from shelfmark.download.clients import transmission # noqa: F401, E402 +from shelfmark.download.clients import deluge # noqa: F401, E402 +from shelfmark.download.clients import rtorrent # noqa: F401, E402 diff --git a/shelfmark/download/clients/base_handler.py b/shelfmark/download/clients/base_handler.py new file mode 100644 index 00000000..9ee547ba --- /dev/null +++ b/shelfmark/download/clients/base_handler.py @@ -0,0 +1,753 @@ +"""Shared download handler for external torrent/usenet clients.""" + +import shutil +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path +from threading import Event +from typing import Callable, Optional + +from shelfmark.core.config import config +from shelfmark.core.logger import setup_logger +from shelfmark.core.models import DownloadTask +from shelfmark.core.utils import is_audiobook +from shelfmark.download.clients import ( + DownloadClient, + DownloadState, + get_client, + list_configured_clients, +) +from shelfmark.download.fs import run_blocking_io +from shelfmark.release_sources import DownloadHandler + +logger = setup_logger(__name__) + +# How often to poll the download client for status (seconds) +POLL_INTERVAL = 2 +# How long to wait for completed files to appear (seconds) +COMPLETED_PATH_RETRY_INTERVAL = 5 +COMPLETED_PATH_MAX_ATTEMPTS = 12 # 12 attempts * 5s = 60s grace period + + +@dataclass(frozen=True) +class DownloadRequest: + """Source-specific download parameters resolved before sending to a client.""" + + url: str + protocol: str + release_name: str + expected_hash: Optional[str] + + +def _diagnose_path_issue(path: str) -> str: + """ + Analyze a path and return diagnostic hints for common issues. + + Args: + path: The path that failed to be accessed + + Returns: + A hint string to help users diagnose the issue. + """ + # Detect Windows-style paths (won't work in Linux containers) + if len(path) >= 2 and path[1] == ':': + return ( + f"Path '{path}' appears to be a Windows path. " + f"Shelfmark runs in Linux and cannot access Windows paths directly. " + f"Ensure your download client uses Linux-style paths (/path/to/files)." + ) + + # Detect backslashes (Windows path separators) + if "\\" in path: + return ( + f"Path '{path}' contains backslashes. " + f"This may indicate a Windows path or incorrect path escaping. " + f"Linux paths should use forward slashes (/)." + ) + + # Generic hint for Linux paths + return ( + f"Path '{path}' is not accessible from Shelfmark's container. " + f"Ensure both containers have matching volume mounts for this directory, " + f"or configure Remote Path Mappings in Settings > Advanced." + ) + + +class ExternalClientHandler(DownloadHandler, ABC): + """Shared lifecycle handler for sources that hand off to torrent/usenet clients.""" + + def __init__(self): + # Track downloads that may need client-side cleanup after Shelfmark completes import. + # task_id -> (client, download_id, protocol) + self._cleanup_refs: dict[str, tuple[DownloadClient, str, str]] = {} + + @abstractmethod + def _resolve_download( + self, + task: DownloadTask, + status_callback: Callable[[str, Optional[str]], None], + ) -> Optional[DownloadRequest]: + """Resolve source-specific task metadata into a client download request.""" + + def _on_download_complete(self, task: DownloadTask) -> None: + """Hook called after successful completion; override for source cleanup.""" + return + + def _get_client(self, protocol: str) -> Optional[DownloadClient]: + """Resolve the active client for a protocol.""" + return get_client(protocol) + + def _list_configured_clients(self) -> list[str]: + """List protocols with configured clients.""" + return list_configured_clients() + + def _poll_interval(self) -> float: + """Polling interval for status checks (seconds).""" + return POLL_INTERVAL + + def _completed_path_retry_interval(self) -> float: + """Retry interval while waiting for completed files (seconds).""" + return COMPLETED_PATH_RETRY_INTERVAL + + def _completed_path_max_attempts(self) -> int: + """Maximum attempts when waiting for completed files.""" + return COMPLETED_PATH_MAX_ATTEMPTS + + def _get_category_for_task(self, client: DownloadClient, task: DownloadTask) -> Optional[str]: + """Get audiobook category if configured and applicable, else None for default.""" + if not is_audiobook(task.content_type): + return None + + # Client-specific audiobook category config keys + audiobook_keys = { + "qbittorrent": "QBITTORRENT_CATEGORY_AUDIOBOOK", + "transmission": "TRANSMISSION_CATEGORY_AUDIOBOOK", + "deluge": "DELUGE_CATEGORY_AUDIOBOOK", + "nzbget": "NZBGET_CATEGORY_AUDIOBOOK", + "sabnzbd": "SABNZBD_CATEGORY_AUDIOBOOK", + } + audiobook_key = audiobook_keys.get(client.name) + return config.get(audiobook_key, "") or None if audiobook_key else None + + def post_process_cleanup(self, task: DownloadTask, success: bool) -> None: + if not success: + self._cleanup_refs.pop(task.task_id, None) + return + + client_ref = self._cleanup_refs.pop(task.task_id, None) + if client_ref is None: + return + + client, download_id, protocol = client_ref + if protocol != "usenet": + return + + # "Move" means copy into ingest then let the usenet client delete its own files. + if config.get("PROWLARR_USENET_ACTION", "move") != "move": + return + + try: + self._delete_local_download_data(client, download_id) + self._remove_usenet_download(client, download_id, delete_files=True, archive=True) + except Exception as e: + logger.warning( + f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}" + ) + + def _remove_usenet_download( + self, + client: DownloadClient, + download_id: str, + *, + delete_files: bool, + archive: bool = True, + ) -> None: + """Remove a usenet download with SABnzbd-specific archive handling.""" + if getattr(client, "name", "") == "sabnzbd": + client.remove(download_id, delete_files=delete_files, archive=archive) + else: + client.remove(download_id, delete_files=delete_files) + + def _delete_local_download_data(self, client: DownloadClient, download_id: str) -> None: + """Best-effort local deletion of client download data.""" + try: + raw_path = client.get_download_path(download_id) + except Exception as e: + logger.debug(f"Failed to resolve download path for {client.name} {download_id}: {e}") + return + + if not raw_path: + logger.debug(f"No download path available for {client.name} {download_id}") + return + + from shelfmark.core.path_mappings import ( + get_client_host_identifier, + parse_remote_path_mappings, + remap_remote_to_local_with_match, + ) + + source_path_obj = Path(raw_path) + host = get_client_host_identifier(client) or "" + mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", []) + mappings = parse_remote_path_mappings(mapping_value) + remapped, matched_mapping = remap_remote_to_local_with_match( + mappings=mappings, + host=host, + remote_path=source_path_obj, + ) + + delete_path = remapped if matched_mapping else source_path_obj + + if str(delete_path) in ("", "/"): + logger.warning(f"Refusing to delete unsafe path for {client.name} {download_id}: {delete_path}") + return + + if not run_blocking_io(delete_path.exists): + logger.debug(f"Local download path does not exist for cleanup: {delete_path}") + return + + try: + if run_blocking_io(delete_path.is_dir): + run_blocking_io(shutil.rmtree, delete_path) + else: + run_blocking_io(delete_path.unlink) + logger.info(f"Deleted local download data for {client.name} {download_id}: {delete_path}") + except Exception as e: + logger.warning(f"Failed to delete local download data for {client.name} {download_id}: {e}") + + def _safe_remove_download(self, client, download_id: str, protocol: str, reason: str) -> None: + """Best-effort removal of a failed/cancelled download from the client. + + Safety policy: + - torrents: never remove or delete client data (avoid breaking seeding) + - usenet: keep legacy behavior (delete client files on removal) + """ + + if protocol != "usenet": + logger.info( + "Skipping download client cleanup for protocol=%s after %s (client=%s id=%s)", + protocol, + reason, + getattr(client, "name", "client"), + download_id, + ) + return + + try: + # Permanent delete for failed usenet downloads (SABnzbd archive=0). + self._delete_local_download_data(client, download_id) + self._remove_usenet_download(client, download_id, delete_files=True, archive=False) + except Exception as e: + logger.warning( + f"Failed to remove download {download_id} from {client.name} after {reason}: {e}" + ) + + def _handle_cancelled_download( + self, + client: DownloadClient, + download_id: str, + protocol: str, + status_callback: Callable[[str, Optional[str]], None], + ) -> None: + if protocol == "usenet": + logger.info(f"Download cancelled, removing from {client.name}: {download_id}") + try: + self._delete_local_download_data(client, download_id) + self._remove_usenet_download(client, download_id, delete_files=True, archive=True) + except Exception as e: + logger.warning( + f"Failed to remove download {download_id} from {client.name} after cancellation: {e}" + ) + else: + logger.info( + f"Download cancelled for protocol={protocol}; leaving in {client.name}: {download_id}" + ) + status_callback("cancelled", "Cancelled") + + def _resolve_download_path_once( + self, + client: DownloadClient, + download_id: str, + *, + log_details: bool, + ) -> tuple[Optional[Path], Optional[str]]: + """Resolve and validate the completed download path once.""" + try: + raw_path = client.get_download_path(download_id) + except Exception as e: + message = ( + f"Could not locate completed download in {client.name} (path not returned). " + f"Check volume mappings and category settings." + ) + if log_details: + logger.error( + f"Failed to resolve download path for {client.name} {download_id}: {e}" + ) + else: + logger.debug( + f"Failed to resolve download path for {client.name} {download_id}: {e}" + ) + return None, message + + if not raw_path: + message = ( + f"Could not locate completed download in {client.name} (path not returned). " + f"Check volume mappings and category settings." + ) + if log_details: + logger.error(f"Download client returned empty path for {client.name} {download_id}") + else: + logger.debug(f"Download client returned empty path for {client.name} {download_id}") + return None, message + + from shelfmark.core.path_mappings import ( + get_client_host_identifier, + parse_remote_path_mappings, + remap_remote_to_local_with_match, + ) + + source_path_obj = Path(raw_path) + host = get_client_host_identifier(client) or "" + mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", []) + mappings = parse_remote_path_mappings(mapping_value) + + if log_details: + logger.debug( + "Attempting path remap: client=%s, host=%s, path=%s, mappings=%s", + client.name, + host, + source_path_obj, + [(m.host, m.remote_path, m.local_path) for m in mappings], + ) + + remapped, matched_mapping = remap_remote_to_local_with_match( + mappings=mappings, + host=host, + remote_path=source_path_obj, + ) + + if log_details: + remapped_exists = run_blocking_io(remapped.exists) + logger.debug( + "Remap result: %s -> %s (exists=%s, changed=%s, matched=%s)", + source_path_obj, + remapped, + remapped_exists, + remapped != source_path_obj, + matched_mapping, + ) + + if matched_mapping: + if run_blocking_io(remapped.exists): + logger.info( + "Remapped download path for %s (%s): %s -> %s", + client.name, + download_id, + source_path_obj, + remapped, + ) + return remapped, None + + message = ( + f"Remapped path '{remapped}' does not exist. " + f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings." + ) + if log_details: + logger.error( + f"Download path does not exist after remapping: {raw_path} -> {remapped}. " + f"Client: {client.name}, ID: {download_id}." + ) + else: + logger.debug( + f"Download path does not exist after remapping: {raw_path} -> {remapped}. " + f"Client: {client.name}, ID: {download_id}." + ) + return None, message + + if mappings: + if run_blocking_io(source_path_obj.exists): + logger.info( + "No remote path mapping matched for %s (%s); using client path: %s", + client.name, + download_id, + source_path_obj, + ) + return source_path_obj, None + + hint = _diagnose_path_issue(raw_path) + message = f"{hint} No remote path mapping matched for client '{client.name}'." + if log_details: + logger.error( + f"Download path does not exist and no remote path mapping matched for {client.name} " + f"({download_id}): {raw_path}. {hint}" + ) + else: + logger.debug( + f"Download path does not exist and no remote path mapping matched for {client.name} " + f"({download_id}): {raw_path}. {hint}" + ) + return None, message + + if not run_blocking_io(source_path_obj.exists): + hint = _diagnose_path_issue(raw_path) + message = hint + if log_details: + logger.error( + f"Download path does not exist: {raw_path}. " + f"Client: {client.name}, ID: {download_id}. {hint}" + ) + else: + logger.debug( + f"Download path does not exist: {raw_path}. " + f"Client: {client.name}, ID: {download_id}. {hint}" + ) + return None, message + + return source_path_obj, None + + def _wait_for_completed_path( + self, + client: DownloadClient, + download_id: str, + *, + cancel_flag: Optional[Event], + status_callback: Callable[[str, Optional[str]], None], + ) -> tuple[Optional[Path], Optional[str]]: + """Wait briefly for completed files to appear on disk.""" + last_error: Optional[str] = None + max_attempts = self._completed_path_max_attempts() + retry_interval = self._completed_path_retry_interval() + + for attempt in range(1, max_attempts + 1): + if cancel_flag and cancel_flag.is_set(): + return None, last_error + + log_details = attempt == max_attempts + resolved_path, error = self._resolve_download_path_once( + client, + download_id, + log_details=log_details, + ) + if resolved_path: + return resolved_path, None + + last_error = error + + if attempt < max_attempts: + status_callback("locating", "Waiting for completed files...") + logger.debug( + "Completed files not available yet for %s (%s) (attempt %d/%d)", + client.name, + download_id, + attempt, + max_attempts, + ) + + if cancel_flag: + if cancel_flag.wait(timeout=retry_interval): + return None, last_error + else: + time.sleep(retry_interval) + + return None, last_error + + def _build_progress_message(self, status) -> str: + """Build a progress message from download status.""" + msg = f"{status.progress:.0f}%" + + if status.download_speed and status.download_speed > 0: + speed_mb = status.download_speed / 1024 / 1024 + msg += f" ({speed_mb:.1f} MB/s)" + + if status.eta and status.eta > 0: + if status.eta < 60: + msg += f" - {status.eta}s left" + elif status.eta < 3600: + msg += f" - {status.eta // 60}m left" + else: + msg += f" - {status.eta // 3600}h {(status.eta % 3600) // 60}m left" + + return msg + + def download( + self, + task: DownloadTask, + cancel_flag: Event, + progress_callback: Callable[[float], None], + status_callback: Callable[[str, Optional[str]], None], + ) -> Optional[str]: + """Execute download via configured torrent/usenet client. Returns file path or None.""" + try: + if cancel_flag.is_set(): + status_callback("cancelled", "Cancelled") + return None + + request = self._resolve_download(task, status_callback) + if not request: + return None + + client = self._get_client(request.protocol) + if not client: + configured = self._list_configured_clients() + if not configured: + status_callback( + "error", + "No download clients configured. Configure qBittorrent or NZBGet in settings.", + ) + else: + status_callback("error", f"No {request.protocol} client configured") + return None + + # Check if this download already exists in the client + status_callback("resolving", f"Checking {client.name}") + category = self._get_category_for_task(client, task) + existing = client.find_existing(request.url, category=category) + + if existing: + download_id, existing_status = existing + logger.info(f"Found existing download in {client.name}: {download_id}") + + # If already complete, skip straight to file handling + if existing_status.complete: + logger.info("Existing download is complete, copying file directly") + status_callback("resolving", "Found existing download, copying to library") + + source_path_obj, path_error = self._wait_for_completed_path( + client=client, + download_id=download_id, + cancel_flag=cancel_flag, + status_callback=status_callback, + ) + if not source_path_obj: + if cancel_flag.is_set(): + return None + status_callback( + "error", + path_error + or f"Could not locate existing download in {client.name}. Check that the file still exists.", + ) + return None + + result = self._handle_completed_file( + source_path=source_path_obj, + protocol=request.protocol, + task=task, + status_callback=status_callback, + ) + + if result: + self._on_download_complete(task) + self._cleanup_refs[task.task_id] = (client, download_id, request.protocol) + return result + + # Existing but still downloading - join the progress polling + logger.info("Existing download in progress, joining poll loop") + status_callback("downloading", "Resuming existing download") + else: + # No existing download - add new + status_callback("resolving", f"Sending to {client.name}") + try: + download_id = client.add_download( + url=request.url, + name=request.release_name, + category=category, + expected_hash=request.expected_hash, + ) + except Exception as e: + logger.error(f"Failed to add to {client.name}: {e}") + status_callback("error", f"Failed to add to {client.name}: {e}") + return None + + logger.info(f"Added to {client.name}: {download_id} for '{request.release_name}'") + + # Poll for progress + return self._poll_and_complete( + client=client, + download_id=download_id, + protocol=request.protocol, + task=task, + cancel_flag=cancel_flag, + progress_callback=progress_callback, + status_callback=status_callback, + ) + + except Exception as e: + logger.error(f"External client download error: {e}") + status_callback("error", str(e)) + return None + + def _poll_and_complete( + self, + client: DownloadClient, + download_id: str, + protocol: str, + task: DownloadTask, + cancel_flag: Event, + progress_callback: Callable[[float], None], + status_callback: Callable[[str, Optional[str]], None], + ) -> Optional[str]: + """Poll the download client for progress and handle completion.""" + poll_interval = self._poll_interval() + # Track consecutive "not found" errors - torrents may take time to appear in client + not_found_count = 0 + max_not_found_retries = 15 # 15 retries * poll interval ~= 30s grace period + + try: + logger.debug(f"Starting poll for {download_id} (content_type={task.content_type})") + while not cancel_flag.is_set(): + status = client.get_status(download_id) + progress_callback(status.progress) + + # Check for completion + if status.complete: + if status.state == DownloadState.ERROR: + logger.error(f"Download {download_id} completed with error: {status.message}") + status_callback("error", status.message or "Download failed") + self._safe_remove_download(client, download_id, protocol, "completion error") + return None + # Download complete - break to handle file + logger.debug(f"Download {download_id} complete, file_path={status.file_path}") + break + + # Check for error state + if status.state == DownloadState.ERROR: + message = (status.message or "").strip() + message_lower = message.lower() + + # Only treat *actual* "not found" as retryable. + # qBittorrent auth/network/API failures should surface immediately (more actionable) + # and must not be confused with "torrent missing". + retryable_not_found = any( + token in message_lower + for token in ( + "torrent not found", + "not found in qbittorrent", + "download not found", + ) + ) + + non_retryable = any( + token in message_lower + for token in ( + "authentication failed", + "cannot connect", + "timed out", + "api request failed", + ) + ) + + if retryable_not_found and not non_retryable: + not_found_count += 1 + if not_found_count < max_not_found_retries: + logger.debug( + f"Download {download_id} not yet visible in client " + f"(attempt {not_found_count}/{max_not_found_retries})" + ) + status_callback("resolving", "Waiting for download client...") + if cancel_flag.wait(timeout=poll_interval): + break + continue + + logger.error( + f"Download {download_id} not found after {max_not_found_retries} attempts" + ) + else: + # Fail fast on actionable errors (auth, connectivity, API issues) + logger.error(f"Download {download_id} error state: {status.message}") + + status_callback("error", status.message or "Download failed") + self._safe_remove_download(client, download_id, protocol, "download error") + return None + + # Reset not-found counter on successful status check + not_found_count = 0 + + # Build status message - use client message if provided, else build progress + msg = status.message or self._build_progress_message(status) + if status.state == DownloadState.PROCESSING: + # Post-processing (e.g., SABnzbd verifying/extracting) + status_callback("resolving", msg) + else: + status_callback("downloading", msg) + + # Wait for next poll (interruptible by cancel) + if cancel_flag.wait(timeout=poll_interval): + break + + # Handle cancellation + if cancel_flag.is_set(): + self._handle_cancelled_download(client, download_id, protocol, status_callback) + return None + + # Handle completed file (wait briefly for files to appear) + source_path_obj, path_error = self._wait_for_completed_path( + client=client, + download_id=download_id, + cancel_flag=cancel_flag, + status_callback=status_callback, + ) + if not source_path_obj: + if cancel_flag.is_set(): + self._handle_cancelled_download(client, download_id, protocol, status_callback) + return None + status_callback( + "error", + path_error + or f"Could not locate completed download in {client.name} (path not returned). Check volume mappings and category settings.", + ) + return None + + result = self._handle_completed_file( + source_path=source_path_obj, + protocol=protocol, + task=task, + status_callback=status_callback, + ) + + # Clean up on success + if result: + self._on_download_complete(task) + self._cleanup_refs[task.task_id] = (client, download_id, protocol) + + return result + + except Exception as e: + logger.error(f"Error during download polling: {e}") + status_callback("error", str(e)) + self._safe_remove_download(client, download_id, protocol, "polling exception") + return None + + def _handle_completed_file( + self, + source_path: Path, + protocol: str, + task: DownloadTask, + status_callback: Callable[[str, Optional[str]], None], + ) -> Optional[str]: + """Handle a completed download and return its path. + + For external download clients (torrents/usenet), staging large payloads into TMP_DIR + is expensive (and can duplicate multi-GB files). Instead, return the client's + completed path and let the orchestrator perform any required transfer (copy/move/ + hardlink) directly from that source. + + Torrents also set ``task.original_download_path`` so the orchestrator can detect + seeding data and enable hardlinking when configured. + """ + try: + if protocol == "torrent": + task.original_download_path = str(source_path) + + logger.debug(f"Download complete, returning original path: {source_path}") + return str(source_path) + + except Exception as e: + logger.error(f"Failed to finalize completed download at {source_path}: {e}") + status_callback("error", f"Failed to finalize completed download: {e}") + return None + + def cancel(self, task_id: str) -> bool: + """Default cancellation (primary cancellation happens via cancel_flag).""" + logger.debug(f"Cancel requested for external client task: {task_id}") + return True diff --git a/shelfmark/release_sources/prowlarr/clients/deluge.py b/shelfmark/download/clients/deluge.py similarity index 99% rename from shelfmark/release_sources/prowlarr/clients/deluge.py rename to shelfmark/download/clients/deluge.py index 92a3ccc8..f0f6b066 100644 --- a/shelfmark/release_sources/prowlarr/clients/deluge.py +++ b/shelfmark/download/clients/deluge.py @@ -20,12 +20,12 @@ import requests from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.utils import normalize_http_url -from shelfmark.release_sources.prowlarr.clients import ( +from shelfmark.download.clients import ( DownloadClient, DownloadStatus, register_client, ) -from shelfmark.release_sources.prowlarr.clients.torrent_utils import ( +from shelfmark.download.clients.torrent_utils import ( extract_torrent_info, ) diff --git a/shelfmark/release_sources/prowlarr/clients/nzbget.py b/shelfmark/download/clients/nzbget.py similarity index 99% rename from shelfmark/release_sources/prowlarr/clients/nzbget.py rename to shelfmark/download/clients/nzbget.py index 6d7706e1..30de99b9 100644 --- a/shelfmark/release_sources/prowlarr/clients/nzbget.py +++ b/shelfmark/download/clients/nzbget.py @@ -12,7 +12,7 @@ import requests from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.utils import normalize_http_url -from shelfmark.release_sources.prowlarr.clients import ( +from shelfmark.download.clients import ( DownloadClient, DownloadStatus, register_client, diff --git a/shelfmark/release_sources/prowlarr/clients/qbittorrent.py b/shelfmark/download/clients/qbittorrent.py similarity index 99% rename from shelfmark/release_sources/prowlarr/clients/qbittorrent.py rename to shelfmark/download/clients/qbittorrent.py index e00dfb13..019f7504 100644 --- a/shelfmark/release_sources/prowlarr/clients/qbittorrent.py +++ b/shelfmark/download/clients/qbittorrent.py @@ -8,12 +8,12 @@ from typing import Optional, Tuple from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.utils import normalize_http_url -from shelfmark.release_sources.prowlarr.clients import ( +from shelfmark.download.clients import ( DownloadClient, DownloadStatus, register_client, ) -from shelfmark.release_sources.prowlarr.clients.torrent_utils import ( +from shelfmark.download.clients.torrent_utils import ( extract_torrent_info, ) diff --git a/shelfmark/release_sources/prowlarr/clients/rtorrent.py b/shelfmark/download/clients/rtorrent.py similarity index 98% rename from shelfmark/release_sources/prowlarr/clients/rtorrent.py rename to shelfmark/download/clients/rtorrent.py index 14d28965..b1ac53d4 100644 --- a/shelfmark/release_sources/prowlarr/clients/rtorrent.py +++ b/shelfmark/download/clients/rtorrent.py @@ -10,12 +10,12 @@ from urllib.parse import urlparse from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.utils import normalize_http_url -from shelfmark.release_sources.prowlarr.clients import ( +from shelfmark.download.clients import ( DownloadClient, DownloadStatus, register_client, ) -from shelfmark.release_sources.prowlarr.clients.torrent_utils import ( +from shelfmark.download.clients.torrent_utils import ( extract_torrent_info, ) diff --git a/shelfmark/release_sources/prowlarr/clients/sabnzbd.py b/shelfmark/download/clients/sabnzbd.py similarity index 99% rename from shelfmark/release_sources/prowlarr/clients/sabnzbd.py rename to shelfmark/download/clients/sabnzbd.py index bcea35fd..68f4c763 100644 --- a/shelfmark/release_sources/prowlarr/clients/sabnzbd.py +++ b/shelfmark/download/clients/sabnzbd.py @@ -12,7 +12,7 @@ import requests from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.utils import normalize_http_url -from shelfmark.release_sources.prowlarr.clients import ( +from shelfmark.download.clients import ( DownloadClient, DownloadStatus, register_client, @@ -195,6 +195,7 @@ class SABnzbdClient(DownloadClient): return response.content def _get_prowlarr_headers(self, url: str) -> dict: + # TODO: Move this source-specific Prowlarr auth handling into a source hook. api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip() if not api_key: return {} diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py new file mode 100644 index 00000000..2b83fa64 --- /dev/null +++ b/shelfmark/download/clients/settings.py @@ -0,0 +1,657 @@ +"""Shared download client settings registration.""" + +from typing import Any, Dict, Optional + +from shelfmark.core.settings_registry import ( + register_settings, + HeadingField, + TextField, + PasswordField, + ActionButton, + SelectField, + TagListField, +) +from shelfmark.core.utils import normalize_http_url + + +# ==================== Test Connection Callbacks ==================== + +def _test_qbittorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Test the qBittorrent connection using current form values.""" + from shelfmark.core.config import config + + current_values = current_values or {} + + raw_url = current_values.get("QBITTORRENT_URL") or config.get("QBITTORRENT_URL", "") + username = current_values.get("QBITTORRENT_USERNAME") or config.get("QBITTORRENT_USERNAME", "") + password = current_values.get("QBITTORRENT_PASSWORD") or config.get("QBITTORRENT_PASSWORD", "") + + if not raw_url: + return {"success": False, "message": "qBittorrent URL is required"} + + try: + from qbittorrentapi import Client + + url = normalize_http_url(raw_url) + if not url: + return {"success": False, "message": "qBittorrent URL is invalid"} + + client = Client(host=url, username=username, password=password) + client.auth_log_in() + api_version = client.app.web_api_version + return {"success": True, "message": f"Connected to qBittorrent (API v{api_version})"} + except ImportError: + return {"success": False, "message": "qbittorrent-api package not installed"} + except Exception as e: + return {"success": False, "message": f"Connection failed: {str(e)}"} + + +def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Test the Transmission connection using current form values.""" + from shelfmark.core.config import config + from shelfmark.download.clients.torrent_utils import ( + parse_transmission_url, + ) + + current_values = current_values or {} + + raw_url = current_values.get("TRANSMISSION_URL") or config.get("TRANSMISSION_URL", "") + username = current_values.get("TRANSMISSION_USERNAME") or config.get("TRANSMISSION_USERNAME", "") + password = current_values.get("TRANSMISSION_PASSWORD") or config.get("TRANSMISSION_PASSWORD", "") + + if not raw_url: + return {"success": False, "message": "Transmission URL is required"} + + url = normalize_http_url(raw_url) + if not url: + return {"success": False, "message": "Transmission URL is invalid"} + + try: + from transmission_rpc import Client + + # Parse URL to extract host, port, and path + protocol, host, port, path = parse_transmission_url(url) + + client_kwargs = { + "host": host, + "port": port, + "path": path, + "username": username if username else None, + "password": password if password else None, + "protocol": protocol, + } + try: + client = Client(**client_kwargs) + except TypeError as e: + if "protocol" not in str(e): + raise + client_kwargs.pop("protocol", None) + client = Client(**client_kwargs) + if protocol == "https" and hasattr(client, "protocol"): + try: + setattr(client, "protocol", protocol) + except Exception: + pass + session = client.get_session() + version = session.version + return {"success": True, "message": f"Connected to Transmission {version}"} + except ImportError: + return {"success": False, "message": "transmission-rpc package not installed"} + except Exception as e: + return {"success": False, "message": f"Connection failed: {str(e)}"} + + +def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Test Deluge Web UI JSON-RPC connection using current form values.""" + from urllib.parse import urlparse + + import requests + from shelfmark.core.config import config + + current_values = current_values or {} + + raw_host = current_values.get("DELUGE_HOST") or config.get("DELUGE_HOST", "localhost") + raw_port = current_values.get("DELUGE_PORT") or config.get("DELUGE_PORT", "8112") + password = current_values.get("DELUGE_PASSWORD") or config.get("DELUGE_PASSWORD", "") + + if not raw_host: + return {"success": False, "message": "Deluge host is required"} + if not password: + return {"success": False, "message": "Deluge password is required"} + + raw_host = str(raw_host) + raw_host = normalize_http_url(raw_host, strip_trailing_slash=False) if raw_host else "" + if not raw_host: + return {"success": False, "message": "Deluge host is invalid"} + + raw_port = str(raw_port or "8112") + + scheme = "http" + base_path = "" + host = raw_host + port = int(raw_port) if raw_port.isdigit() else 8112 + + # Allow DELUGE_HOST to be a full URL (e.g. http://deluge:8112) + if raw_host.startswith(("http://", "https://")): + parsed = urlparse(raw_host) + scheme = parsed.scheme or "http" + host = parsed.hostname or "localhost" + if parsed.port is not None: + port = parsed.port + base_path = (parsed.path or "").rstrip("/") + else: + # Allow "host:port" in DELUGE_HOST for convenience. + if ":" in raw_host and raw_host.count(":") == 1: + host_part, port_part = raw_host.split(":", 1) + if host_part and port_part.isdigit(): + host = host_part + port = int(port_part) + + rpc_url = f"{scheme}://{host}:{port}{base_path}/json" + + def rpc_call(session: requests.Session, rpc_id: int, method: str, *params: Any) -> Any: + payload = {"id": rpc_id, "method": method, "params": list(params)} + resp = session.post(rpc_url, json=payload, timeout=15) + resp.raise_for_status() + data = resp.json() + if data.get("error"): + error = data["error"] + if isinstance(error, dict): + raise Exception(error.get("message") or str(error)) + raise Exception(str(error)) + return data.get("result") + + def get_daemon_version(session: requests.Session, rpc_id: int) -> Any: + try: + methods = rpc_call(session, rpc_id, "system.listMethods") + if isinstance(methods, list) and "daemon.get_version" in methods: + return rpc_call(session, rpc_id + 1, "daemon.get_version") + except Exception: + # Fall back to daemon.info to preserve existing behavior. + pass + + return rpc_call(session, rpc_id + 1, "daemon.info") + + try: + session = requests.Session() + + if rpc_call(session, 1, "auth.login", password) is not True: + return {"success": False, "message": "Deluge Web UI authentication failed"} + + if rpc_call(session, 2, "web.connected") is not True: + hosts = rpc_call(session, 3, "web.get_hosts") or [] + if not hosts: + return { + "success": False, + "message": "Deluge Web UI isn't connected to Deluge core (no hosts configured). Add/connect a daemon in Deluge Web UI → Connection Manager.", + } + + host_id = hosts[0][0] + for entry in hosts: + if isinstance(entry, list) and len(entry) >= 2 and entry[1] in {"127.0.0.1", "localhost"}: + host_id = entry[0] + break + + rpc_call(session, 4, "web.connect", host_id) + + if rpc_call(session, 5, "web.connected") is not True: + return { + "success": False, + "message": "Deluge Web UI couldn't connect to Deluge core. Check Deluge Web UI → Connection Manager.", + } + + version = get_daemon_version(session, 6) + return {"success": True, "message": f"Connected to Deluge {version}"} + + except requests.exceptions.ConnectionError: + return {"success": False, "message": "Could not connect to Deluge Web UI"} + except requests.exceptions.Timeout: + return {"success": False, "message": "Connection timed out"} + except Exception as e: + return {"success": False, "message": f"Connection failed: {str(e)}"} + + +def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Test the rTorrent connection using current form values.""" + from shelfmark.core.config import config + from urllib.parse import urlparse + from xmlrpc.client import ServerProxy + + current_values = current_values or {} + + raw_url = current_values.get("RTORRENT_URL") or config.get("RTORRENT_URL", "") + username = current_values.get("RTORRENT_USERNAME") or config.get("RTORRENT_USERNAME", "") + password = current_values.get("RTORRENT_PASSWORD") or config.get("RTORRENT_PASSWORD", "") + + if not raw_url: + return {"success": False, "message": "rTorrent URL is required"} + + url = normalize_http_url(raw_url) + if not url: + return {"success": False, "message": "rTorrent URL is invalid"} + + try: + # Add HTTP auth to URL if credentials provided + if username and password: + parsed = urlparse(url) + url = f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}" + + rpc = ServerProxy(url.rstrip("/")) + version = rpc.system.client_version() + return {"success": True, "message": f"Connected to rTorrent {version}"} + except Exception as e: + return {"success": False, "message": f"Connection failed: {str(e)}"} + + +def _test_nzbget_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Test the NZBGet connection using current form values.""" + import requests + from shelfmark.core.config import config + + current_values = current_values or {} + + raw_url = current_values.get("NZBGET_URL") or config.get("NZBGET_URL", "") + username = current_values.get("NZBGET_USERNAME") or config.get("NZBGET_USERNAME", "nzbget") + password = current_values.get("NZBGET_PASSWORD") or config.get("NZBGET_PASSWORD", "") + + if not raw_url: + return {"success": False, "message": "NZBGet URL is required"} + + url = normalize_http_url(raw_url) + if not url: + return {"success": False, "message": "NZBGet URL is invalid"} + + try: + rpc_url = f"{url.rstrip('/')}/jsonrpc" + payload = {"jsonrpc": "2.0", "method": "status", "params": [], "id": 1} + response = requests.post(rpc_url, json=payload, auth=(username, password), timeout=30) + response.raise_for_status() + result = response.json() + if "error" in result and result["error"]: + raise Exception(result["error"].get("message", "RPC error")) + version = result.get("result", {}).get("Version", "unknown") + return {"success": True, "message": f"Connected to NZBGet {version}"} + except requests.exceptions.ConnectionError: + return {"success": False, "message": "Could not connect to NZBGet"} + except requests.exceptions.Timeout: + return {"success": False, "message": "Connection timed out"} + except Exception as e: + return {"success": False, "message": f"Connection failed: {str(e)}"} + + +def _test_sabnzbd_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Test the SABnzbd connection using current form values.""" + import requests + from shelfmark.core.config import config + + current_values = current_values or {} + + raw_url = current_values.get("SABNZBD_URL") or config.get("SABNZBD_URL", "") + api_key = current_values.get("SABNZBD_API_KEY") or config.get("SABNZBD_API_KEY", "") + + if not raw_url: + return {"success": False, "message": "SABnzbd URL is required"} + + url = normalize_http_url(raw_url) + if not url: + return {"success": False, "message": "SABnzbd URL is invalid"} + if not api_key: + return {"success": False, "message": "API key is required"} + + try: + api_url = f"{url.rstrip('/')}/api" + params = {"apikey": api_key, "mode": "version", "output": "json"} + response = requests.get(api_url, params=params, timeout=30) + response.raise_for_status() + result = response.json() + version = result.get("version", "unknown") + return {"success": True, "message": f"Connected to SABnzbd {version}"} + except requests.exceptions.ConnectionError: + return {"success": False, "message": "Could not connect to SABnzbd"} + except requests.exceptions.Timeout: + return {"success": False, "message": "Connection timed out"} + except Exception as e: + return {"success": False, "message": f"Connection failed: {str(e)}"} + + +# ==================== Download Clients Tab ==================== + +@register_settings( + name="prowlarr_clients", + display_name="Download Clients", + icon="cog", + order=110, +) +def prowlarr_clients_settings(): + """Download client settings shared by external release sources.""" + return [ + # --- Torrent Client Selection --- + HeadingField( + key="torrent_heading", + title="Torrent Client", + description="Select and configure a torrent client for downloading torrent releases.", + ), + SelectField( + key="PROWLARR_TORRENT_CLIENT", + label="Torrent Client", + description="Choose which torrent client to use", + options=[ + {"value": "", "label": "None"}, + {"value": "qbittorrent", "label": "qBittorrent"}, + {"value": "transmission", "label": "Transmission"}, + {"value": "deluge", "label": "Deluge"}, + {"value": "rtorrent", "label": "rTorrent"}, + ], + default="", + ), + + # --- qBittorrent Settings --- + TextField( + key="QBITTORRENT_URL", + label="qBittorrent URL", + description="Web UI URL of your qBittorrent instance", + placeholder="http://qbittorrent:8080", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, + ), + TextField( + key="QBITTORRENT_USERNAME", + label="Username", + description="qBittorrent Web UI username", + placeholder="admin", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, + ), + PasswordField( + key="QBITTORRENT_PASSWORD", + label="Password", + description="qBittorrent Web UI password", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, + ), + ActionButton( + key="test_qbittorrent", + label="Test Connection", + description="Verify your qBittorrent configuration", + style="primary", + callback=_test_qbittorrent_connection, + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, + ), + TextField( + key="QBITTORRENT_CATEGORY", + label="Book Category", + description="Category to assign to book downloads in qBittorrent", + placeholder="books", + default="books", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, + ), + TextField( + key="QBITTORRENT_CATEGORY_AUDIOBOOK", + label="Audiobook Category", + description="Category for audiobook downloads. Leave empty to use the book category.", + placeholder="", + default="", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, + ), + TagListField( + key="QBITTORRENT_TAG", + label="Tags", + description="Tag(s) to assign to qBittorrent downloads. Leave empty for no tags.", + placeholder="", + default=[], + normalize_urls=False, + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, + ), + + # --- Transmission Settings --- + TextField( + key="TRANSMISSION_URL", + label="Transmission URL", + description="URL of your Transmission instance (use https:// for TLS)", + placeholder="http://transmission:9091", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, + ), + TextField( + key="TRANSMISSION_USERNAME", + label="Username", + description="Transmission RPC username (if authentication enabled)", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, + ), + PasswordField( + key="TRANSMISSION_PASSWORD", + label="Password", + description="Transmission RPC password", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, + ), + ActionButton( + key="test_transmission", + label="Test Connection", + description="Verify your Transmission configuration", + style="primary", + callback=_test_transmission_connection, + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, + ), + TextField( + key="TRANSMISSION_CATEGORY", + label="Book Label", + description="Label to assign to book downloads in Transmission", + placeholder="books", + default="books", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, + ), + TextField( + key="TRANSMISSION_CATEGORY_AUDIOBOOK", + label="Audiobook Label", + description="Label for audiobook downloads. Leave empty to use the book label.", + placeholder="", + default="", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, + ), + + # --- Deluge Settings --- + TextField( + key="DELUGE_HOST", + label="Deluge Web UI Host/URL", + description="Hostname/IP or full URL of your Deluge Web UI (deluge-web)", + placeholder="http://deluge:8112", + default="localhost", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, + ), + TextField( + key="DELUGE_PORT", + label="Deluge Web UI Port", + description="Deluge Web UI port (default: 8112)", + placeholder="8112", + default="8112", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, + ), + PasswordField( + key="DELUGE_PASSWORD", + label="Password", + description="Deluge Web UI password (default: deluge)", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, + ), + ActionButton( + key="test_deluge", + label="Test Connection", + description="Verify your Deluge configuration", + style="primary", + callback=_test_deluge_connection, + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, + ), + TextField( + key="DELUGE_CATEGORY", + label="Book Label", + description="Label to assign to book downloads in Deluge", + placeholder="books", + default="books", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, + ), + TextField( + key="DELUGE_CATEGORY_AUDIOBOOK", + label="Audiobook Label", + description="Label for audiobook downloads. Leave empty to use the book label.", + placeholder="", + default="", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, + ), + + # --- rTorrent Settings --- + TextField( + key="RTORRENT_URL", + label="rTorrent URL", + description="XML-RPC URL of your rTorrent instance", + placeholder="http://rtorrent:6881/RPC2", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, + ), + TextField( + key="RTORRENT_USERNAME", + label="Username", + description="HTTP Basic auth username (if authentication enabled)", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, + ), + PasswordField( + key="RTORRENT_PASSWORD", + label="Password", + description="HTTP Basic auth password", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, + ), + ActionButton( + key="test_rtorrent", + label="Test Connection", + description="Verify your rTorrent configuration", + style="primary", + callback=_test_rtorrent_connection, + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, + ), + TextField( + key="RTORRENT_LABEL", + label="Book Label", + description="Label to assign to book downloads in rTorrent", + placeholder="cwabd", + default="cwabd", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, + ), + TextField( + key="RTORRENT_DOWNLOAD_DIR", + label="Download Directory", + description="Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified)", + placeholder="/downloads", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, + ), + # Note: Torrent client download path must be mounted identically in both containers. + # Torrents are always copied (not moved) to preserve seeding capability. + + # --- Usenet Client Selection --- + HeadingField( + key="usenet_heading", + title="Usenet Client", + description="Select and configure a usenet client for downloading NZB releases.", + ), + SelectField( + key="PROWLARR_USENET_CLIENT", + label="Usenet Client", + description="Choose which usenet client to use", + options=[ + {"value": "", "label": "None"}, + {"value": "nzbget", "label": "NZBGet"}, + {"value": "sabnzbd", "label": "SABnzbd"}, + ], + default="", + ), + + # --- NZBGet Settings --- + TextField( + key="NZBGET_URL", + label="NZBGet URL", + description="URL of your NZBGet instance", + placeholder="http://nzbget:6789", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, + ), + TextField( + key="NZBGET_USERNAME", + label="Username", + description="NZBGet control username", + placeholder="nzbget", + default="nzbget", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, + ), + PasswordField( + key="NZBGET_PASSWORD", + label="Password", + description="NZBGet control password", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, + ), + ActionButton( + key="test_nzbget", + label="Test Connection", + description="Verify your NZBGet configuration", + style="primary", + callback=_test_nzbget_connection, + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, + ), + TextField( + key="NZBGET_CATEGORY", + label="Book Category", + description="Category to assign to book downloads in NZBGet", + placeholder="Books", + default="Books", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, + ), + TextField( + key="NZBGET_CATEGORY_AUDIOBOOK", + label="Audiobook Category", + description="Category for audiobook downloads. Leave empty to use the book category.", + placeholder="", + default="", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, + ), + + # --- SABnzbd Settings --- + TextField( + key="SABNZBD_URL", + label="SABnzbd URL", + description="URL of your SABnzbd instance", + placeholder="http://sabnzbd:8080", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, + ), + PasswordField( + key="SABNZBD_API_KEY", + label="API Key", + description="Found in SABnzbd: Config > General > API Key", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, + ), + ActionButton( + key="test_sabnzbd", + label="Test Connection", + description="Verify your SABnzbd configuration", + style="primary", + callback=_test_sabnzbd_connection, + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, + ), + TextField( + key="SABNZBD_CATEGORY", + label="Book Category", + description="Category to assign to book downloads in SABnzbd", + placeholder="books", + default="books", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, + ), + TextField( + key="SABNZBD_CATEGORY_AUDIOBOOK", + label="Audiobook Category", + description="Category for audiobook downloads. Leave empty to use the book category.", + placeholder="", + default="", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, + ), + # Note: Usenet client download path must be mounted identically in both containers. + SelectField( + key="PROWLARR_USENET_ACTION", + label="NZB Completion Action", + description="Move deletes the job from your usenet client after import; Copy keeps it in the client", + options=[ + {"value": "move", "label": "Move"}, + {"value": "copy", "label": "Copy"}, + ], + default="move", + show_when={"field": "PROWLARR_USENET_CLIENT", "notEmpty": True}, + ), + ] diff --git a/shelfmark/release_sources/prowlarr/clients/torrent_utils.py b/shelfmark/download/clients/torrent_utils.py similarity index 99% rename from shelfmark/release_sources/prowlarr/clients/torrent_utils.py rename to shelfmark/download/clients/torrent_utils.py index e99d4736..10c4653b 100644 --- a/shelfmark/release_sources/prowlarr/clients/torrent_utils.py +++ b/shelfmark/download/clients/torrent_utils.py @@ -72,6 +72,7 @@ def extract_torrent_info( return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False) headers: dict[str, str] = {"Accept": "application/x-bittorrent"} + # TODO: Move this source-specific Prowlarr auth handling into a source hook. api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip() if api_key: headers["X-Api-Key"] = api_key diff --git a/shelfmark/release_sources/prowlarr/clients/transmission.py b/shelfmark/download/clients/transmission.py similarity index 98% rename from shelfmark/release_sources/prowlarr/clients/transmission.py rename to shelfmark/download/clients/transmission.py index 6a23a367..f5108f6f 100644 --- a/shelfmark/release_sources/prowlarr/clients/transmission.py +++ b/shelfmark/download/clients/transmission.py @@ -10,12 +10,12 @@ from typing import Optional, Tuple from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.utils import normalize_http_url -from shelfmark.release_sources.prowlarr.clients import ( +from shelfmark.download.clients import ( DownloadClient, DownloadStatus, register_client, ) -from shelfmark.release_sources.prowlarr.clients.torrent_utils import ( +from shelfmark.download.clients.torrent_utils import ( extract_torrent_info, parse_transmission_url, ) diff --git a/shelfmark/download/http.py b/shelfmark/download/http.py index 2e03df74..82907ee9 100644 --- a/shelfmark/download/http.py +++ b/shelfmark/download/http.py @@ -177,13 +177,23 @@ def html_get_page( cancel_flag: Optional[Event] = None, status_callback: Optional[Callable[[str, Optional[str]], None]] = None, allow_bypasser_fallback: bool = True, -) -> str: + include_response_url: bool = False, + success_delay: float = 1.0, +) -> str | tuple[str, str]: """Fetch HTML content from a URL with retry mechanism. Args: allow_bypasser_fallback: If False, 403 errors will trigger mirror rotation instead of switching to the bypasser. Use for search operations. + include_response_url: If True, return `(html, final_url)` to expose the + resolved response URL after redirects. + success_delay: Optional delay (seconds) after successful fetch. """ + def _result(html: str, response_url: str) -> str | tuple[str, str]: + if include_response_url: + return html, response_url + return html + retry = retry if retry is not None else app_config.MAX_RETRY selector = selector or network.AAMirrorSelector() original_url = url @@ -194,7 +204,7 @@ def html_get_page( # Check for cancellation before each attempt if cancel_flag and cancel_flag.is_set(): logger.info(f"html_get_page cancelled before attempt {attempt}") - return "" + return _result("", current_url) try: if use_bypasser_now and _is_cf_bypass_enabled(): @@ -218,10 +228,10 @@ def html_get_page( heartbeat_thread.start() try: result = get_bypassed_page(current_url, selector, cancel_flag) - return result or "" + return _result(result or "", current_url) except Exception as e: logger.warning(f"Bypasser error: {type(e).__name__}: {e}") - return "" + return _result("", current_url) finally: heartbeat_stop.set() if heartbeat_thread: @@ -270,7 +280,7 @@ def html_get_page( redirect_host, current_url, ) - return "" + return _result("", current_url) new_url = _try_rotation(original_url, current_url, selector) if new_url: @@ -288,7 +298,7 @@ def html_get_page( redirect_host, current_url, ) - return "" + return _result("", current_url) # Same-host redirect (relative or absolute) - follow manually. redirects_followed += 1 @@ -298,8 +308,9 @@ def html_get_page( continue response.raise_for_status() - time.sleep(1) - return response.text + if success_delay > 0: + time.sleep(success_delay) + return _result(response.text, response.url) except Exception as e: status = _get_status_code(e) @@ -313,7 +324,7 @@ def html_get_page( current_url = new_url continue logger.warning(f"403 error, mirrors exhausted: {current_url}") - return "" + return _result("", current_url) if _is_cf_bypass_enabled() and not use_bypasser_now: # Before switching to bypasser, check if cookies have become available @@ -330,12 +341,12 @@ def html_get_page( use_bypasser_now = True continue logger.warning(f"403 error, giving up: {current_url}") - return "" + return _result("", current_url) # 404 = Not found if status == 404: logger.warning(f"404 error: {current_url}") - return "" + return _result("", current_url) # Try mirror/DNS rotation on retryable errors if _is_retryable_error(e): @@ -351,7 +362,7 @@ def html_get_page( else: logger.error(f"Giving up after {retry} attempts: {current_url}") - return "" + return _result("", current_url) def download_url( diff --git a/shelfmark/release_sources/audiobookbay/handler.py b/shelfmark/release_sources/audiobookbay/handler.py index 4406590a..0f79b0f8 100644 --- a/shelfmark/release_sources/audiobookbay/handler.py +++ b/shelfmark/release_sources/audiobookbay/handler.py @@ -1,164 +1,65 @@ -"""AudiobookBay download handler - extracts magnet links and sends to torrent clients.""" +"""AudiobookBay download handler - resolves magnet links and uses shared client lifecycle.""" -from threading import Event from typing import Callable, Optional +from urllib.parse import urlparse from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.models import DownloadTask -from shelfmark.core.utils import is_audiobook -from shelfmark.release_sources import DownloadHandler, register_handler +from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients +from shelfmark.download.clients.base_handler import DownloadRequest, ExternalClientHandler +from shelfmark.release_sources import register_handler from shelfmark.release_sources.audiobookbay import scraper -from shelfmark.release_sources.prowlarr.clients import ( - DownloadClient, - get_client, - list_configured_clients, -) +from shelfmark.release_sources.audiobookbay.utils import normalize_hostname logger = setup_logger(__name__) @register_handler("audiobookbay") -class AudiobookBayHandler(DownloadHandler): +class AudiobookBayHandler(ExternalClientHandler): """Handler for AudiobookBay downloads via configured torrent client.""" - - def _get_category_for_task(self, client: DownloadClient, task: DownloadTask) -> Optional[str]: - """Get audiobook category if configured and applicable, else None for default.""" - if not is_audiobook(task.content_type): - return None - - # Client-specific audiobook category config keys - audiobook_keys = { - "qbittorrent": "QBITTORRENT_CATEGORY_AUDIOBOOK", - "transmission": "TRANSMISSION_CATEGORY_AUDIOBOOK", - "deluge": "DELUGE_CATEGORY_AUDIOBOOK", - } - audiobook_key = audiobook_keys.get(client.name) - if audiobook_key: - category = config.get(audiobook_key, "") - if category: - return category - - # Fallback to general category - general_keys = { - "qbittorrent": "QBITTORRENT_CATEGORY", - "transmission": "TRANSMISSION_CATEGORY", - "deluge": "DELUGE_CATEGORY", - } - general_key = general_keys.get(client.name) - if general_key: - return config.get(general_key, "") or None - - return None - - def download( + + def _get_client(self, protocol: str) -> Optional[DownloadClient]: + """Compatibility shim so module-level patching still works in tests.""" + return get_client(protocol) + + def _list_configured_clients(self) -> list[str]: + """Compatibility shim so module-level patching still works in tests.""" + return list_configured_clients() + + def _resolve_download( self, task: DownloadTask, - cancel_flag: Event, - progress_callback: Callable[[float], None], status_callback: Callable[[str, Optional[str]], None], - ) -> Optional[str]: - """Execute download by extracting magnet link and sending to torrent client. - - Args: - task: Download task with task_id containing detail URL - cancel_flag: Event to check for cancellation - progress_callback: Called with progress percentage (0-100) - status_callback: Called with (status, message) for status updates - - Returns: - None (torrents don't return file path immediately) - """ - try: - # Check for cancellation before starting - if cancel_flag.is_set(): - logger.info(f"Download cancelled before starting: {task.task_id}") - status_callback("cancelled", "Cancelled") - return None - - # task.task_id contains the detail page URL - detail_url = task.task_id - hostname = config.get("ABB_HOSTNAME", "audiobookbay.lu") - - # Extract magnet link from detail page - status_callback("resolving", "Extracting magnet link") - magnet_link = scraper.extract_magnet_link(detail_url, hostname) - - if not magnet_link: - status_callback("error", "Failed to extract magnet link from detail page") - return None - - logger.info(f"Extracted magnet link: {magnet_link[:100]}...") - - # Get torrent client - client = get_client("torrent") - if not client: - configured = list_configured_clients() - if not configured: - status_callback("error", "No torrent clients configured. Configure qBittorrent or Transmission in settings.") - else: - status_callback("error", "No torrent client configured") - return None - - # Check if this download already exists in the client - status_callback("resolving", f"Checking {client.name}") - category = self._get_category_for_task(client, task) - existing = client.find_existing(magnet_link, category=category) - - if existing: - download_id, existing_status = existing - logger.info(f"Found existing download in {client.name}: {download_id}") - - if existing_status.complete: - logger.info("Existing download is complete") - status_callback("resolving", "Found existing download") - # Return the path from the existing download - file_path = client.get_download_path(download_id) - if file_path: - return file_path - else: - status_callback("error", "Could not locate existing download path") - return None - else: - logger.info("Existing download in progress") - status_callback("downloading", "Resuming existing download") - # Poll for completion (simplified - could reuse Prowlarr's polling logic) - # For now, just return None and let the orchestrator handle it - return None - - # Add new download - status_callback("resolving", f"Sending to {client.name}") - try: - release_name = task.title or "Unknown" - category = self._get_category_for_task(client, task) - download_id = client.add_download( - url=magnet_link, - name=release_name, - category=category, - expected_hash=None, # Extract from magnet if needed - ) - logger.info(f"Added to {client.name}: {download_id} for '{release_name}'") - status_callback("downloading", "Download started") - except Exception as e: - logger.error(f"Failed to add to {client.name}: {e}") - status_callback("error", f"Failed to add to {client.name}: {e}") - return None - - # Torrents don't return file path immediately - # The orchestrator will handle polling via the download client + ) -> Optional[DownloadRequest]: + """Resolve ABB detail page into a magnet-link download request.""" + detail_url = task.task_id + hostname = normalize_hostname(config.get("ABB_HOSTNAME", "")) + if not hostname: + hostname = normalize_hostname(urlparse(detail_url).hostname) + + status_callback("resolving", "Extracting magnet link") + magnet_link = scraper.extract_magnet_link(detail_url, hostname) + + if not magnet_link: + status_callback("error", "Failed to extract magnet link from detail page") return None - - except Exception as e: - logger.error(f"AudiobookBay download error: {e}") - status_callback("error", str(e)) - return None - + + logger.info(f"Extracted magnet link for task {task.task_id}") + + return DownloadRequest( + url=magnet_link, + protocol="torrent", + release_name=task.title or "Unknown", + expected_hash=None, + ) + def cancel(self, task_id: str) -> bool: """Cancel an in-progress download. - - Torrents can't be cancelled from Shelfmark side. - User must cancel in torrent client. + + Shelfmark can stop waiting via the queue cancel flag, but once a magnet has + been sent to the torrent client we do not remove it client-side. Users must + cancel/remove it in their torrent client UI. """ logger.debug(f"Cancel requested for AudiobookBay task: {task_id}") - # Torrents are managed by the client, we can't cancel them here return False diff --git a/shelfmark/release_sources/audiobookbay/scraper.py b/shelfmark/release_sources/audiobookbay/scraper.py index c901ae52..12841ec6 100644 --- a/shelfmark/release_sources/audiobookbay/scraper.py +++ b/shelfmark/release_sources/audiobookbay/scraper.py @@ -5,12 +5,11 @@ import time from typing import List, Optional, Dict from urllib.parse import quote -import requests from bs4 import BeautifulSoup from shelfmark.core.config import config from shelfmark.core.logger import setup_logger -from shelfmark.download import network +from shelfmark.download import http as downloader logger = setup_logger(__name__) @@ -24,16 +23,57 @@ DEFAULT_TRACKERS = [ "udp://tracker.leechers-paradise.org:6969", ] -# Required headers to avoid blocking -REQUEST_HEADERS = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36' -} +# ABB request behavior tuning +SEARCH_PAGE_RETRY_ATTEMPTS = 2 +DETAIL_PAGE_RETRY_ATTEMPTS = 2 + +# Legacy search parameter used by older ABB flows +LEGACY_CATEGORY_QUERY = "undefined%2Cundefined" + +# Precompiled patterns used while parsing result cards +LANGUAGE_PATTERN = re.compile(r"Language:\s*([A-Za-z]+)") +POSTED_PATTERN = re.compile(r"Posted:\s*(\d+\s+[A-Za-z]+\s+\d{4})") +FORMAT_PATTERN = re.compile(r"Format:\s*([A-Za-z0-9]+)") +BITRATE_PATTERN = re.compile(r"Bitrate:\s*([\d]+\s*[A-Za-z/]+)") +SIZE_PATTERN = re.compile(r"File Size:\s*([\d.]+)\s*([A-Za-z]+)") +INFO_HASH_LABEL_PATTERN = re.compile(r"Info Hash", re.IGNORECASE) + + +def _build_search_url( + hostname: str, + page: int, + query_encoded: str, + *, + include_legacy_category: bool = False, +) -> str: + """Build an ABB search URL, optionally including legacy category params.""" + url = f"https://{hostname}/page/{page}/?s={query_encoded}" + if include_legacy_category: + return f"{url}&cat={LEGACY_CATEGORY_QUERY}" + return url + + +def _is_homepage_redirect(final_url: str, hostname: str) -> bool: + """Detect whether ABB redirected a search request to its homepage.""" + normalized_final = (final_url or "").rstrip("/") + normalized_home = f"https://{hostname}".rstrip("/") + return normalized_final in {normalized_home, f"{normalized_home}/"} + + +def _encode_search_query(query: str, exact_phrase: bool) -> str: + """Encode search query using ABB's space-plus style and optional exact phrase wrapping.""" + search_query = query.strip() + if exact_phrase and search_query and not (search_query.startswith('"') and search_query.endswith('"')): + search_query = f"\"{search_query}\"" + # Keep ABB-friendly encoding style (spaces as '+') while percent-encoding quotes. + return search_query.replace('"', "%22").replace(" ", "+") def search_audiobookbay( query: str, - max_pages: int = 5, - hostname: str = "audiobookbay.lu" + max_pages: int = 1, + hostname: str = "audiobookbay.lu", + exact_phrase: bool = False, ) -> List[Dict[str, str]]: """Search AudiobookBay for audiobooks matching the query. @@ -41,6 +81,7 @@ def search_audiobookbay( query: Search query string max_pages: Maximum number of pages to fetch hostname: AudiobookBay hostname (e.g., "audiobookbay.lu") + exact_phrase: Wrap query in quotes for exact phrase matching Returns: List of dicts with keys: title, link, cover, language, format, bitrate, size, posted_date @@ -51,28 +92,48 @@ def search_audiobookbay( # Iterate through pages for page in range(1, max_pages + 1): # Construct URL - use + for spaces (matching audiobookbay-automated implementation) - # This avoids aggressive encoding that PHP-based sites may reject - query_encoded = query.replace(' ', '+') - url = f"https://{hostname}/page/{page}/?s={query_encoded}&cat=undefined%2Cundefined" + # This avoids aggressive encoding that PHP-based sites may reject. + query_encoded = _encode_search_query(query, exact_phrase) + primary_url = _build_search_url(hostname, page, query_encoded) try: - # Make request with proxy support - response = requests.get( - url, - headers=REQUEST_HEADERS, - proxies=network.get_proxies(url), - timeout=30, - allow_redirects=True + # Reuse shared HTTP fetch logic (without bypasser) + page_html, final_url = downloader.html_get_page( + primary_url, + retry=SEARCH_PAGE_RETRY_ATTEMPTS, + use_bypasser=False, + allow_bypasser_fallback=False, + include_response_url=True, + success_delay=0, ) + + # Legacy compatibility fallback: retry once with legacy category param if the + # first request fails/redirects unexpectedly. + if not page_html or _is_homepage_redirect(final_url, hostname): + legacy_url = _build_search_url( + hostname, + page, + query_encoded, + include_legacy_category=True, + ) + legacy_html, legacy_final_url = downloader.html_get_page( + legacy_url, + retry=SEARCH_PAGE_RETRY_ATTEMPTS, + use_bypasser=False, + allow_bypasser_fallback=False, + include_response_url=True, + success_delay=0, + ) + if legacy_html: + page_html = legacy_html + final_url = legacy_final_url - if response.status_code != 200: - logger.warning(f"Failed to fetch page {page}. Status Code: {response.status_code}") + if not page_html: + logger.warning(f"Failed to fetch page {page}") break # Check if we were redirected to the homepage (search was rejected/blocked) - final_url = response.url.rstrip('/') - base_url = f"https://{hostname}".rstrip('/') - if final_url == base_url or final_url == f"{base_url}/": + if _is_homepage_redirect(final_url, hostname): # Search was redirected to homepage - this means the search failed # This can happen due to geo-blocking, rate limiting, or invalid query format if page == 1: @@ -80,7 +141,7 @@ def search_audiobookbay( break # Parse HTML - soup = BeautifulSoup(response.text, 'html.parser') + soup = BeautifulSoup(page_html, 'html.parser') # Extract book entries posts = soup.select('.post') @@ -120,7 +181,7 @@ def search_audiobookbay( post_info = post.select_one('.postInfo') if post_info: info_text = post_info.get_text(separator=' ', strip=True).replace('\xa0', ' ') - lang_match = re.search(r'Language:\s*([A-Za-z]+)', info_text) + lang_match = LANGUAGE_PATTERN.search(info_text) if lang_match: language = lang_match.group(1).strip() @@ -135,24 +196,29 @@ def search_audiobookbay( content_text = post_content.get_text(separator=' ', strip=True).replace('\xa0', ' ') # Extract posted date - posted_match = re.search(r'Posted:\s*(\d+\s+[A-Za-z]+\s+\d{4})', content_text) + posted_match = POSTED_PATTERN.search(content_text) if posted_match: posted_date = posted_match.group(1).strip() # Extract format (e.g., "M4B", "MP3") - format_match = re.search(r'Format:\s*([A-Za-z0-9]+)', content_text) + format_match = FORMAT_PATTERN.search(content_text) if format_match: format_type = format_match.group(1).strip() # Extract bitrate (e.g., "256 Kbps") - bitrate_match = re.search(r'Bitrate:\s*([\d]+\s*[A-Za-z/]+)', content_text) + bitrate_match = BITRATE_PATTERN.search(content_text) if bitrate_match: bitrate = bitrate_match.group(1).strip() - # Extract file size (e.g., "11.68 GBs") - size_match = re.search(r'File Size:\s*([\d.]+)\s*([A-Za-z]+)', content_text) + # Extract file size (e.g., "11.68 GBs" -> normalized to "11.68 GB") + size_match = SIZE_PATTERN.search(content_text) if size_match: - size_str = f"{size_match.group(1)} {size_match.group(2)}" + size_value = size_match.group(1) + size_unit = size_match.group(2).strip() + if size_unit.lower().endswith("s"): + size_unit = size_unit[:-1] + size_unit = size_unit.upper() + size_str = f"{size_value} {size_unit}" results.append({ 'title': title, @@ -171,10 +237,6 @@ def search_audiobookbay( # Rate limiting delay between pages if page < max_pages and rate_limit_delay > 0: time.sleep(rate_limit_delay) - - except requests.exceptions.RequestException as e: - logger.warning(f"Request error on page {page}: {e}") - break except Exception as e: logger.error(f"Unexpected error on page {page}: {e}") break @@ -198,18 +260,19 @@ def extract_magnet_link( """ try: # Fetch detail page - response = requests.get( + detail_html = downloader.html_get_page( details_url, - headers=REQUEST_HEADERS, - proxies=network.get_proxies(details_url), - timeout=30 + retry=DETAIL_PAGE_RETRY_ATTEMPTS, + use_bypasser=False, + allow_bypasser_fallback=False, + success_delay=0, ) - if response.status_code != 200: - logger.warning(f"Failed to fetch details page. Status Code: {response.status_code}") + if not detail_html: + logger.warning("Failed to fetch details page") return None - soup = BeautifulSoup(response.text, 'html.parser') + soup = BeautifulSoup(detail_html, 'html.parser') # 1. Extract Info Hash # Look for Info Hash and get next sibling value @@ -224,7 +287,7 @@ def extract_magnet_link( # Alternative: search for text containing "Info Hash" and get next element if not info_hash: - for elem in soup.find_all(string=re.compile(r'Info Hash', re.IGNORECASE)): + for elem in soup.find_all(string=INFO_HASH_LABEL_PATTERN): parent = elem.parent if parent and parent.name == 'td': next_td = parent.find_next_sibling('td') @@ -263,9 +326,6 @@ def extract_magnet_link( logger.debug(f"Generated Magnet Link: {magnet_link[:100]}...") return magnet_link - except requests.exceptions.RequestException as e: - logger.error(f"Request error extracting magnet link: {e}") - return None except Exception as e: logger.error(f"Failed to extract magnet link: {e}") return None diff --git a/shelfmark/release_sources/audiobookbay/settings.py b/shelfmark/release_sources/audiobookbay/settings.py index 0173cc35..10098c0a 100644 --- a/shelfmark/release_sources/audiobookbay/settings.py +++ b/shelfmark/release_sources/audiobookbay/settings.py @@ -1,28 +1,16 @@ """AudiobookBay settings registration.""" from shelfmark.core.settings_registry import ( - register_group, register_settings, CheckboxField, TextField, NumberField, - HeadingField, -) - - -# ==================== Register Group ==================== - -register_group( - name="audiobookbay", - display_name="AudiobookBay", - icon="download", - order=45, # After Prowlarr (order 40) ) # ==================== Register Settings ==================== -@register_settings("audiobookbay_config", "Configuration", group="audiobookbay", order=1) +@register_settings("audiobookbay_config", "AudiobookBay", icon="download", order=45) def audiobookbay_config_settings(): """AudiobookBay configuration settings.""" return [ @@ -35,20 +23,28 @@ def audiobookbay_config_settings(): TextField( key="ABB_HOSTNAME", label="Hostname", - description="AudiobookBay domain (e.g., audiobookbay.lu, audiobookbay.is)", - placeholder="audiobookbay.lu", - default="audiobookbay.lu", + description="AudiobookBay domain (e.g., audiobookbay.lu, audiobookbay.is). Required to enable searches.", + placeholder="", + default="", + required=True, show_when={"field": "ABB_ENABLED", "value": True}, ), NumberField( key="ABB_PAGE_LIMIT", label="Max Pages to Search", description="Maximum number of search result pages to fetch (1-10).", - default=5, + default=1, min_value=1, max_value=10, show_when={"field": "ABB_ENABLED", "value": True}, ), + CheckboxField( + key="ABB_EXACT_PHRASE", + label="Prefer Exact-Phrase Search", + description="Wrap generated queries in quotes for stricter matching. If no results are found, Shelfmark retries without quotes.", + default=False, + show_when={"field": "ABB_ENABLED", "value": True}, + ), NumberField( key="ABB_RATE_LIMIT_DELAY", label="Rate Limit Delay (seconds)", @@ -59,17 +55,3 @@ def audiobookbay_config_settings(): show_when={"field": "ABB_ENABLED", "value": True}, ), ] - - -# ==================== Download Clients Tab ==================== - -@register_settings("audiobookbay_clients", "Download Clients", group="audiobookbay", order=2) -def audiobookbay_clients_settings(): - """AudiobookBay download client settings.""" - return [ - HeadingField( - key="abb_torrent_heading", - title="Torrent Client", - description="The AudiobookBay integration uses the torrent client that is configured under 'Prowlarr' > 'Download Clients'.", - ), - ] diff --git a/shelfmark/release_sources/audiobookbay/source.py b/shelfmark/release_sources/audiobookbay/source.py index dd50e81b..6e811454 100644 --- a/shelfmark/release_sources/audiobookbay/source.py +++ b/shelfmark/release_sources/audiobookbay/source.py @@ -1,6 +1,7 @@ """AudiobookBay release source - searches AudiobookBay for audiobook torrents.""" import hashlib +import re from typing import List, Optional, TYPE_CHECKING if TYPE_CHECKING: @@ -21,7 +22,7 @@ from shelfmark.release_sources import ( ColumnColorHint, ) from shelfmark.release_sources.audiobookbay import scraper -from shelfmark.release_sources.audiobookbay.utils import parse_size +from shelfmark.release_sources.audiobookbay.utils import normalize_hostname, parse_size logger = setup_logger(__name__) @@ -64,6 +65,31 @@ LANGUAGE_MAP = { } +def _split_title_and_author(raw_title: str) -> tuple[str, Optional[str]]: + """Split titles in the form 'Title - Author' into title and author. + + Args: + raw_title: The raw title string from the scrape. + + Returns: + (title, author) where author is None if split is unavailable. + """ + if not raw_title: + return "", None + + cleaned_title = raw_title.strip() + if " - " not in cleaned_title: + return cleaned_title, None + + title_part, author_part = cleaned_title.rsplit(" - ", 1) + title_part = title_part.strip() + author_part = author_part.strip() + if not title_part or not author_part: + return cleaned_title, None + + return title_part, author_part + + def _map_language(language: str) -> Optional[str]: """Map language name to ISO 639-1 code. @@ -80,6 +106,28 @@ def _map_language(language: str) -> Optional[str]: return LANGUAGE_MAP.get(lang_lower, lang_lower) +def _parse_bitrate_to_kbps(bitrate: Optional[str]) -> Optional[int]: + """Parse bitrate string to an integer Kbps value. + + Args: + bitrate: Human-readable bitrate (e.g., "128 Kbps") + + Returns: + Bitrate value in Kbps as integer, or None if parsing fails. + """ + if not bitrate: + return None + + match = re.search(r"(\d+(?:\.\d+)?)\s*kbps", bitrate, re.IGNORECASE) + if not match: + return None + + try: + return int(float(match.group(1))) + except ValueError: + return None + + def _generate_source_id(detail_url: str) -> str: """Generate a unique source ID from detail URL.""" return hashlib.md5(detail_url.encode()).hexdigest() @@ -115,8 +163,12 @@ class AudiobookBaySource(ReleaseSource): if content_type != "audiobook": return [] - hostname = config.get("ABB_HOSTNAME", "audiobookbay.lu") - max_pages = config.get("ABB_PAGE_LIMIT", 5) + hostname = normalize_hostname(config.get("ABB_HOSTNAME", "")) + if not hostname: + logger.debug("AudiobookBay hostname is not configured") + return [] + max_pages = config.get("ABB_PAGE_LIMIT", 1) + exact_phrase = bool(config.get("ABB_EXACT_PHRASE", False)) # Build search query from plan if plan.manual_query: @@ -141,8 +193,19 @@ class AudiobookBaySource(ReleaseSource): results = scraper.search_audiobookbay( query=query_lower, max_pages=max_pages, - hostname=hostname + hostname=hostname, + exact_phrase=exact_phrase, ) + + # For auto-generated queries, fallback to broad matching if exact phrase returns nothing. + if exact_phrase and not results and not plan.manual_query: + logger.info("No exact phrase results, retrying AudiobookBay search without quotes") + results = scraper.search_audiobookbay( + query=query_lower, + max_pages=max_pages, + hostname=hostname, + exact_phrase=False, + ) # Extract query words for relevance checking query_words = set(word.lower() for word in query_lower.split() if len(word) > 2) @@ -150,13 +213,14 @@ class AudiobookBaySource(ReleaseSource): releases = [] for result in results: try: - title = result['title'] + raw_title = result['title'] + title, author = _split_title_and_author(raw_title) + title_for_filter = raw_title.lower() # Basic relevance check: ensure title contains at least one query word # This filters out homepage "Latest" feed items that may leak through if query_words: - title_lower = title.lower() - if not any(word in title_lower for word in query_words): + if not any(word in title_for_filter for word in query_words): logger.debug(f"Filtering out irrelevant result: {title}") continue @@ -169,6 +233,8 @@ class AudiobookBaySource(ReleaseSource): size_bytes = parse_size(size_str) if size_str else None language_raw = result.get('language') language_code = _map_language(language_raw) if language_raw else None + bitrate = result.get('bitrate') + bitrate_kbps = _parse_bitrate_to_kbps(bitrate) # Create Release object release = Release( @@ -189,9 +255,12 @@ class AudiobookBaySource(ReleaseSource): extra={ "preview": result.get('cover'), "detail_url": result['link'], - "bitrate": result.get('bitrate'), + "bitrate": bitrate, + "bitrate_value": bitrate_kbps, "posted_date": result.get('posted_date'), + "title_raw": raw_title, "language_raw": language_raw, # Keep original for reference + "author": author, # Parsed author from title pattern } ) releases.append(release) @@ -207,13 +276,13 @@ class AudiobookBaySource(ReleaseSource): return [] def is_available(self) -> bool: - """Check if AudiobookBay source is enabled.""" - return config.get("ABB_ENABLED", False) is True + """Check if AudiobookBay source is enabled and configured.""" + return config.get("ABB_ENABLED", False) is True and bool(normalize_hostname(config.get("ABB_HOSTNAME", ""))) def get_column_config(self) -> ReleaseColumnConfig: """Get column configuration for AudiobookBay releases. - Shows title, language, format, and size columns. + Shows title, language, format, bitrate, and size columns. No seeders/peers since ABB doesn't show this on search page. """ return ReleaseColumnConfig( @@ -239,6 +308,17 @@ class AudiobookBaySource(ReleaseSource): color_hint=ColumnColorHint(type="map", value="format"), uppercase=True, ), + ColumnSchema( + key="extra.bitrate", + label="Bitrate", + render_type=ColumnRenderType.NUMBER, + align=ColumnAlign.CENTER, + width="72px", + hide_mobile=False, + fallback="", + sortable=True, + sort_key="extra.bitrate_value", + ), ColumnSchema( key="size", label="Size", @@ -246,8 +326,10 @@ class AudiobookBaySource(ReleaseSource): align=ColumnAlign.CENTER, width="80px", hide_mobile=False, + sortable=True, + sort_key="size_bytes", ), ], - grid_template="minmax(0,2fr) 60px 80px 80px", + grid_template="minmax(0,2fr) 60px 80px 72px 80px", supported_filters=["format", "language"], # Enable format and language filters ) diff --git a/shelfmark/release_sources/audiobookbay/utils.py b/shelfmark/release_sources/audiobookbay/utils.py index b90f9543..e174d361 100644 --- a/shelfmark/release_sources/audiobookbay/utils.py +++ b/shelfmark/release_sources/audiobookbay/utils.py @@ -4,44 +4,52 @@ import re from typing import Optional -def sanitize_title(title: str) -> str: - """Remove characters that are invalid in filenames. - - Args: - title: Book title - - Returns: - Sanitized title +def normalize_hostname(raw: Optional[str]) -> str: + """Normalize a user-supplied hostname for URL construction. + + Strips whitespace, scheme prefixes, trailing slashes, and paths so that + values like "https://audiobookbay.lu/" or " audiobookbay.lu/ " all + resolve to "audiobookbay.lu". """ - return re.sub(r'[<>:"/\\|?*]', '', title).strip() + if not raw or not isinstance(raw, str): + return "" + cleaned = raw.strip() + # Strip scheme + for prefix in ("https://", "http://"): + if cleaned.lower().startswith(prefix): + cleaned = cleaned[len(prefix):] + break + # Strip path and trailing slashes + cleaned = cleaned.split("/")[0].strip() + return cleaned def parse_size(size_str: Optional[str]) -> Optional[int]: """Parse size string to bytes. - + Args: size_str: Size string (e.g., "1.5 GB", "500 MB", "11.68 GBs") - + Returns: Size in bytes, or None if parsing fails """ if not size_str: return None - + # Match number and unit, handling "GBs" as well as "GB" (case-insensitive) - match = re.search(r'([\d.]+)\s*([BKMGT]B?)S?', size_str.upper()) + match = re.search(r"([\d.]+)\s*([BKMGT]B?)S?", size_str.upper()) if not match: return None - + value = float(match.group(1)) unit = match.group(2) - + multipliers = { - 'B': 1, - 'KB': 1024, - 'MB': 1024 ** 2, - 'GB': 1024 ** 3, - 'TB': 1024 ** 4, + "B": 1, + "KB": 1024, + "MB": 1024 ** 2, + "GB": 1024 ** 3, + "TB": 1024 ** 4, } - + return int(value * multipliers.get(unit, 1)) diff --git a/shelfmark/release_sources/prowlarr/__init__.py b/shelfmark/release_sources/prowlarr/__init__.py index 56c901d9..63afe8c4 100644 --- a/shelfmark/release_sources/prowlarr/__init__.py +++ b/shelfmark/release_sources/prowlarr/__init__.py @@ -7,7 +7,6 @@ across multiple indexers (torrent and usenet). Includes: - ProwlarrSource: Search integration with Prowlarr - ProwlarrHandler: Download handling via external clients -- Download clients: qBittorrent (torrents), NZBGet (usenet) """ # Import submodules to trigger decorator registration @@ -15,12 +14,12 @@ from shelfmark.release_sources.prowlarr import source # noqa: F401 from shelfmark.release_sources.prowlarr import handler # noqa: F401 from shelfmark.release_sources.prowlarr import settings # noqa: F401 -# Import clients to trigger client registration -# This is in a try/except to handle optional dependencies gracefully +# Import shared download clients/settings to trigger registration. +# This is in a try/except to handle optional dependencies gracefully. try: - from shelfmark.release_sources.prowlarr import clients # noqa: F401 + from shelfmark.download import clients # noqa: F401 + from shelfmark.download.clients import settings as client_settings # noqa: F401 except ImportError as e: - # Log but don't fail - clients require optional dependencies import logging - logging.getLogger(__name__).debug(f"Prowlarr clients not loaded: {e}") + logging.getLogger(__name__).debug(f"Download clients not loaded: {e}") diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index c3570d25..2ce40971 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -1,721 +1,92 @@ -"""Prowlarr download handler - executes downloads via torrent/usenet clients.""" +"""Prowlarr download handler - resolves releases and delegates lifecycle to shared clients.""" -import shutil -import time -from pathlib import Path -from threading import Event from typing import Callable, Optional -from shelfmark.core.config import config +from shelfmark.core.config import config # noqa: F401 (compat patch target in tests) from shelfmark.core.logger import setup_logger from shelfmark.core.models import DownloadTask -from shelfmark.core.utils import is_audiobook -from shelfmark.download.fs import run_blocking_io -from shelfmark.release_sources import DownloadHandler, register_handler -from shelfmark.release_sources.prowlarr.cache import get_release, remove_release -from shelfmark.release_sources.prowlarr.clients import ( - DownloadClient, - DownloadState, - get_client, - list_configured_clients, +from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients +from shelfmark.download.clients.base_handler import ( + COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS, + COMPLETED_PATH_RETRY_INTERVAL as _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL, + POLL_INTERVAL as _DEFAULT_POLL_INTERVAL, + DownloadRequest, + ExternalClientHandler, ) +from shelfmark.release_sources import register_handler +from shelfmark.release_sources.prowlarr.cache import get_release, remove_release from shelfmark.release_sources.prowlarr.utils import get_preferred_download_url, get_protocol logger = setup_logger(__name__) -# How often to poll the download client for status (seconds) -POLL_INTERVAL = 2 -# How long to wait for completed files to appear (seconds) -COMPLETED_PATH_RETRY_INTERVAL = 5 -COMPLETED_PATH_MAX_ATTEMPTS = 12 # 12 attempts * 5s = 60s grace period - - -def _diagnose_path_issue(path: str) -> str: - """ - Analyze a path and return diagnostic hints for common issues. - - Args: - path: The path that failed to be accessed - - Returns: - A hint string to help users diagnose the issue. - """ - # Detect Windows-style paths (won't work in Linux containers) - if len(path) >= 2 and path[1] == ':': - return ( - f"Path '{path}' appears to be a Windows path. " - f"Shelfmark runs in Linux and cannot access Windows paths directly. " - f"Ensure your download client uses Linux-style paths (/path/to/files)." - ) - - # Detect backslashes (Windows path separators) - if '\\' in path: - return ( - f"Path '{path}' contains backslashes. " - f"This may indicate a Windows path or incorrect path escaping. " - f"Linux paths should use forward slashes (/)." - ) - - # Generic hint for Linux paths - return ( - f"Path '{path}' is not accessible from Shelfmark's container. " - f"Ensure both containers have matching volume mounts for this directory, " - f"or configure Remote Path Mappings in Settings > Advanced." - ) +# Backwards-compat constants for tests patching this module. +POLL_INTERVAL = _DEFAULT_POLL_INTERVAL +COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL +COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS @register_handler("prowlarr") -class ProwlarrHandler(DownloadHandler): +class ProwlarrHandler(ExternalClientHandler): """Handler for Prowlarr downloads via configured torrent or usenet client.""" - def __init__(self): - # Track downloads that may need client-side cleanup after Shelfmark completes import. - # task_id -> (client, download_id, protocol) - self._cleanup_refs: dict[str, tuple[DownloadClient, str, str]] = {} + def _get_client(self, protocol: str) -> Optional[DownloadClient]: + """Compatibility shim so module-level patching still works in tests.""" + return get_client(protocol) - def _get_category_for_task(self, client, task: DownloadTask) -> Optional[str]: - """Get audiobook category if configured and applicable, else None for default.""" - if not is_audiobook(task.content_type): - return None + def _list_configured_clients(self) -> list[str]: + """Compatibility shim so module-level patching still works in tests.""" + return list_configured_clients() - # Client-specific audiobook category config keys - audiobook_keys = { - "qbittorrent": "QBITTORRENT_CATEGORY_AUDIOBOOK", - "transmission": "TRANSMISSION_CATEGORY_AUDIOBOOK", - "deluge": "DELUGE_CATEGORY_AUDIOBOOK", - "nzbget": "NZBGET_CATEGORY_AUDIOBOOK", - "sabnzbd": "SABNZBD_CATEGORY_AUDIOBOOK", - } - audiobook_key = audiobook_keys.get(client.name) - return config.get(audiobook_key, "") or None if audiobook_key else None + def _poll_interval(self) -> float: + return POLL_INTERVAL - def post_process_cleanup(self, task: DownloadTask, success: bool) -> None: - if not success: - self._cleanup_refs.pop(task.task_id, None) - return + def _completed_path_retry_interval(self) -> float: + return COMPLETED_PATH_RETRY_INTERVAL - client_ref = self._cleanup_refs.pop(task.task_id, None) - if client_ref is None: - return + def _completed_path_max_attempts(self) -> int: + return COMPLETED_PATH_MAX_ATTEMPTS - client, download_id, protocol = client_ref - if protocol != "usenet": - return - - # "Move" means copy into ingest then let the usenet client delete its own files. - if config.get("PROWLARR_USENET_ACTION", "move") != "move": - return - - try: - self._delete_local_download_data(client, download_id) - self._remove_usenet_download(client, download_id, delete_files=True, archive=True) - except Exception as e: - logger.warning(f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}") - - def _remove_usenet_download( - self, - client: DownloadClient, - download_id: str, - *, - delete_files: bool, - archive: bool = True, - ) -> None: - """Remove a usenet download with SABnzbd-specific archive handling.""" - if getattr(client, "name", "") == "sabnzbd": - client.remove(download_id, delete_files=delete_files, archive=archive) - else: - client.remove(download_id, delete_files=delete_files) - - def _delete_local_download_data(self, client: DownloadClient, download_id: str) -> None: - """Best-effort local deletion of client download data.""" - try: - raw_path = client.get_download_path(download_id) - except Exception as e: - logger.debug(f"Failed to resolve download path for {client.name} {download_id}: {e}") - return - - if not raw_path: - logger.debug(f"No download path available for {client.name} {download_id}") - return - - from shelfmark.core.path_mappings import ( - get_client_host_identifier, - parse_remote_path_mappings, - remap_remote_to_local_with_match, - ) - - source_path_obj = Path(raw_path) - host = get_client_host_identifier(client) or "" - mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", []) - mappings = parse_remote_path_mappings(mapping_value) - remapped, matched_mapping = remap_remote_to_local_with_match( - mappings=mappings, - host=host, - remote_path=source_path_obj, - ) - - delete_path = remapped if matched_mapping else source_path_obj - - if str(delete_path) in ("", "/"): - logger.warning(f"Refusing to delete unsafe path for {client.name} {download_id}: {delete_path}") - return - - if not run_blocking_io(delete_path.exists): - logger.debug(f"Local download path does not exist for cleanup: {delete_path}") - return - - try: - if run_blocking_io(delete_path.is_dir): - run_blocking_io(shutil.rmtree, delete_path) - else: - run_blocking_io(delete_path.unlink) - logger.info(f"Deleted local download data for {client.name} {download_id}: {delete_path}") - except Exception as e: - logger.warning(f"Failed to delete local download data for {client.name} {download_id}: {e}") - - def _safe_remove_download(self, client, download_id: str, protocol: str, reason: str) -> None: - """Best-effort removal of a failed/cancelled download from the client. - - Safety policy: - - torrents: never remove or delete client data (avoid breaking seeding) - - usenet: keep legacy behavior (delete client files on removal) - """ - - if protocol != "usenet": - logger.info( - "Skipping download client cleanup for protocol=%s after %s (client=%s id=%s)", - protocol, - reason, - getattr(client, "name", "client"), - download_id, - ) - return - - try: - # Permanent delete for failed usenet downloads (SABnzbd archive=0). - self._delete_local_download_data(client, download_id) - self._remove_usenet_download(client, download_id, delete_files=True, archive=False) - except Exception as e: - logger.warning( - f"Failed to remove download {download_id} from {client.name} after {reason}: {e}" - ) - - def _handle_cancelled_download( - self, - client: DownloadClient, - download_id: str, - protocol: str, - status_callback: Callable[[str, Optional[str]], None], - ) -> None: - if protocol == "usenet": - logger.info(f"Download cancelled, removing from {client.name}: {download_id}") - try: - self._delete_local_download_data(client, download_id) - self._remove_usenet_download(client, download_id, delete_files=True, archive=True) - except Exception as e: - logger.warning( - f"Failed to remove download {download_id} from {client.name} after cancellation: {e}" - ) - else: - logger.info( - f"Download cancelled for protocol={protocol}; leaving in {client.name}: {download_id}" - ) - status_callback("cancelled", "Cancelled") - - def _resolve_download_path_once( - self, - client: DownloadClient, - download_id: str, - *, - log_details: bool, - ) -> tuple[Optional[Path], Optional[str]]: - """Resolve and validate the completed download path once.""" - try: - raw_path = client.get_download_path(download_id) - except Exception as e: - message = ( - f"Could not locate completed download in {client.name} (path not returned). " - f"Check volume mappings and category settings." - ) - if log_details: - logger.error( - f"Failed to resolve download path for {client.name} {download_id}: {e}" - ) - else: - logger.debug( - f"Failed to resolve download path for {client.name} {download_id}: {e}" - ) - return None, message - - if not raw_path: - message = ( - f"Could not locate completed download in {client.name} (path not returned). " - f"Check volume mappings and category settings." - ) - if log_details: - logger.error(f"Download client returned empty path for {client.name} {download_id}") - else: - logger.debug(f"Download client returned empty path for {client.name} {download_id}") - return None, message - - from shelfmark.core.path_mappings import ( - get_client_host_identifier, - parse_remote_path_mappings, - remap_remote_to_local_with_match, - ) - - source_path_obj = Path(raw_path) - host = get_client_host_identifier(client) or "" - mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", []) - mappings = parse_remote_path_mappings(mapping_value) - - if log_details: - logger.debug( - "Attempting path remap: client=%s, host=%s, path=%s, mappings=%s", - client.name, - host, - source_path_obj, - [(m.host, m.remote_path, m.local_path) for m in mappings], - ) - - remapped, matched_mapping = remap_remote_to_local_with_match( - mappings=mappings, - host=host, - remote_path=source_path_obj, - ) - - if log_details: - remapped_exists = run_blocking_io(remapped.exists) - logger.debug( - "Remap result: %s -> %s (exists=%s, changed=%s, matched=%s)", - source_path_obj, - remapped, - remapped_exists, - remapped != source_path_obj, - matched_mapping, - ) - - if matched_mapping: - if run_blocking_io(remapped.exists): - logger.info( - "Remapped download path for %s (%s): %s -> %s", - client.name, - download_id, - source_path_obj, - remapped, - ) - return remapped, None - - message = ( - f"Remapped path '{remapped}' does not exist. " - f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings." - ) - if log_details: - logger.error( - f"Download path does not exist after remapping: {raw_path} -> {remapped}. " - f"Client: {client.name}, ID: {download_id}." - ) - else: - logger.debug( - f"Download path does not exist after remapping: {raw_path} -> {remapped}. " - f"Client: {client.name}, ID: {download_id}." - ) - return None, message - - if mappings: - if run_blocking_io(source_path_obj.exists): - logger.info( - "No remote path mapping matched for %s (%s); using client path: %s", - client.name, - download_id, - source_path_obj, - ) - return source_path_obj, None - - hint = _diagnose_path_issue(raw_path) - message = f"{hint} No remote path mapping matched for client '{client.name}'." - if log_details: - logger.error( - f"Download path does not exist and no remote path mapping matched for {client.name} " - f"({download_id}): {raw_path}. {hint}" - ) - else: - logger.debug( - f"Download path does not exist and no remote path mapping matched for {client.name} " - f"({download_id}): {raw_path}. {hint}" - ) - return None, message - - if not run_blocking_io(source_path_obj.exists): - hint = _diagnose_path_issue(raw_path) - message = hint - if log_details: - logger.error( - f"Download path does not exist: {raw_path}. " - f"Client: {client.name}, ID: {download_id}. {hint}" - ) - else: - logger.debug( - f"Download path does not exist: {raw_path}. " - f"Client: {client.name}, ID: {download_id}. {hint}" - ) - return None, message - - return source_path_obj, None - - def _wait_for_completed_path( - self, - client: DownloadClient, - download_id: str, - *, - cancel_flag: Optional[Event], - status_callback: Callable[[str, Optional[str]], None], - ) -> tuple[Optional[Path], Optional[str]]: - """Wait briefly for completed files to appear on disk.""" - last_error: Optional[str] = None - - for attempt in range(1, COMPLETED_PATH_MAX_ATTEMPTS + 1): - if cancel_flag and cancel_flag.is_set(): - return None, last_error - - log_details = attempt == COMPLETED_PATH_MAX_ATTEMPTS - resolved_path, error = self._resolve_download_path_once( - client, - download_id, - log_details=log_details, - ) - if resolved_path: - return resolved_path, None - - last_error = error - - if attempt < COMPLETED_PATH_MAX_ATTEMPTS: - status_callback("locating", "Waiting for completed files...") - logger.debug( - "Completed files not available yet for %s (%s) (attempt %d/%d)", - client.name, - download_id, - attempt, - COMPLETED_PATH_MAX_ATTEMPTS, - ) - - if cancel_flag: - if cancel_flag.wait(timeout=COMPLETED_PATH_RETRY_INTERVAL): - return None, last_error - else: - time.sleep(COMPLETED_PATH_RETRY_INTERVAL) - - return None, last_error - - def _build_progress_message(self, status) -> str: - """Build a progress message from download status.""" - msg = f"{status.progress:.0f}%" - - if status.download_speed and status.download_speed > 0: - speed_mb = status.download_speed / 1024 / 1024 - msg += f" ({speed_mb:.1f} MB/s)" - - if status.eta and status.eta > 0: - if status.eta < 60: - msg += f" - {status.eta}s left" - elif status.eta < 3600: - msg += f" - {status.eta // 60}m left" - else: - msg += f" - {status.eta // 3600}h {(status.eta % 3600) // 60}m left" - - return msg - - def download( + def _resolve_download( self, task: DownloadTask, - cancel_flag: Event, - progress_callback: Callable[[float], None], status_callback: Callable[[str, Optional[str]], None], - ) -> Optional[str]: - """Execute download via configured torrent/usenet client. Returns file path or None.""" - try: - # Look up the cached release - prowlarr_result = get_release(task.task_id) - if not prowlarr_result: - logger.warning(f"Release cache miss: {task.task_id}") - status_callback("error", "Release not found in cache (may have expired)") - return None - - # Extract download URL - download_url = get_preferred_download_url(prowlarr_result) - if not download_url: - status_callback("error", "No download URL available") - return None - - # Determine protocol - protocol = get_protocol(prowlarr_result) - if protocol == "unknown": - status_callback("error", "Could not determine download protocol") - return None - - # Get the appropriate download client - client = get_client(protocol) - if not client: - configured = list_configured_clients() - if not configured: - status_callback("error", "No download clients configured. Configure qBittorrent or NZBGet in settings.") - else: - status_callback("error", f"No {protocol} client configured") - return None - - # Check if this download already exists in the client - status_callback("resolving", f"Checking {client.name}") - category = self._get_category_for_task(client, task) - existing = client.find_existing(download_url, category=category) - - if existing: - download_id, existing_status = existing - logger.info(f"Found existing download in {client.name}: {download_id}") - - # If already complete, skip straight to file handling - if existing_status.complete: - logger.info("Existing download is complete, copying file directly") - status_callback("resolving", "Found existing download, copying to library") - - source_path_obj, path_error = self._wait_for_completed_path( - client=client, - download_id=download_id, - cancel_flag=cancel_flag, - status_callback=status_callback, - ) - if not source_path_obj: - if cancel_flag.is_set(): - return None - status_callback( - "error", - path_error - or f"Could not locate existing download in {client.name}. Check that the file still exists.", - ) - return None - - result = self._handle_completed_file( - source_path=source_path_obj, - protocol=protocol, - task=task, - status_callback=status_callback, - ) - - if result: - remove_release(task.task_id) - self._cleanup_refs[task.task_id] = (client, download_id, protocol) - return result - - # Existing but still downloading - join the progress polling - logger.info(f"Existing download in progress, joining poll loop") - status_callback("downloading", "Resuming existing download") - else: - # No existing download - add new - status_callback("resolving", f"Sending to {client.name}") - try: - release_name = prowlarr_result.get("title") or task.title or "Unknown" - category = self._get_category_for_task(client, task) - expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None - download_id = client.add_download( - url=download_url, - name=release_name, - category=category, - expected_hash=expected_hash, - ) - except Exception as e: - logger.error(f"Failed to add to {client.name}: {e}") - status_callback("error", f"Failed to add to {client.name}: {e}") - return None - - logger.info(f"Added to {client.name}: {download_id} for '{release_name}'") - - # Poll for progress - return self._poll_and_complete( - client=client, - download_id=download_id, - protocol=protocol, - task=task, - cancel_flag=cancel_flag, - progress_callback=progress_callback, - status_callback=status_callback, - ) - - except Exception as e: - logger.error(f"Prowlarr download error: {e}") - status_callback("error", str(e)) + ) -> Optional[DownloadRequest]: + """Resolve Prowlarr cache entry into download request parameters.""" + # Look up the cached release + prowlarr_result = get_release(task.task_id) + if not prowlarr_result: + logger.warning(f"Release cache miss: {task.task_id}") + status_callback("error", "Release not found in cache (may have expired)") return None - def _poll_and_complete( - self, - client, - download_id: str, - protocol: str, - task: DownloadTask, - cancel_flag: Event, - progress_callback: Callable[[float], None], - status_callback: Callable[[str, Optional[str]], None], - ) -> Optional[str]: - """Poll the download client for progress and handle completion.""" - # Track consecutive "not found" errors - torrents may take time to appear in client - not_found_count = 0 - max_not_found_retries = 15 # 15 retries * 2s poll = 30s grace period - - try: - logger.debug(f"Starting poll for {download_id} (content_type={task.content_type})") - while not cancel_flag.is_set(): - status = client.get_status(download_id) - progress_callback(status.progress) - - # Check for completion - if status.complete: - if status.state == DownloadState.ERROR: - logger.error(f"Download {download_id} completed with error: {status.message}") - status_callback("error", status.message or "Download failed") - self._safe_remove_download(client, download_id, protocol, "completion error") - return None - # Download complete - break to handle file - logger.debug(f"Download {download_id} complete, file_path={status.file_path}") - break - - # Check for error state - if status.state == DownloadState.ERROR: - message = (status.message or "").strip() - message_lower = message.lower() - - # Only treat *actual* "not found" as retryable. - # qBittorrent auth/network/API failures should surface immediately (more actionable) - # and must not be confused with "torrent missing". - retryable_not_found = any( - token in message_lower - for token in ( - "torrent not found", - "not found in qbittorrent", - "download not found", - ) - ) - - non_retryable = any( - token in message_lower - for token in ( - "authentication failed", - "cannot connect", - "timed out", - "api request failed", - ) - ) - - if retryable_not_found and not non_retryable: - not_found_count += 1 - if not_found_count < max_not_found_retries: - logger.debug( - f"Download {download_id} not yet visible in client " - f"(attempt {not_found_count}/{max_not_found_retries})" - ) - status_callback("resolving", "Waiting for download client...") - if cancel_flag.wait(timeout=POLL_INTERVAL): - break - continue - - logger.error( - f"Download {download_id} not found after {max_not_found_retries} attempts" - ) - else: - # Fail fast on actionable errors (auth, connectivity, API issues) - logger.error(f"Download {download_id} error state: {status.message}") - - status_callback("error", status.message or "Download failed") - self._safe_remove_download(client, download_id, protocol, "download error") - return None - - # Reset not-found counter on successful status check - not_found_count = 0 - - # Build status message - use client message if provided, else build progress - msg = status.message or self._build_progress_message(status) - if status.state == DownloadState.PROCESSING: - # Post-processing (e.g., SABnzbd verifying/extracting) - status_callback("resolving", msg) - else: - status_callback("downloading", msg) - - # Wait for next poll (interruptible by cancel) - if cancel_flag.wait(timeout=POLL_INTERVAL): - break - - # Handle cancellation - if cancel_flag.is_set(): - self._handle_cancelled_download(client, download_id, protocol, status_callback) - return None - - # Handle completed file (wait briefly for files to appear) - source_path_obj, path_error = self._wait_for_completed_path( - client=client, - download_id=download_id, - cancel_flag=cancel_flag, - status_callback=status_callback, - ) - if not source_path_obj: - if cancel_flag.is_set(): - self._handle_cancelled_download(client, download_id, protocol, status_callback) - return None - status_callback( - "error", - path_error - or f"Could not locate completed download in {client.name} (path not returned). Check volume mappings and category settings.", - ) - return None - - result = self._handle_completed_file( - source_path=source_path_obj, - protocol=protocol, - task=task, - status_callback=status_callback, - ) - - # Clean up on success - if result: - remove_release(task.task_id) - self._cleanup_refs[task.task_id] = (client, download_id, protocol) - - return result - - except Exception as e: - logger.error(f"Error during download polling: {e}") - status_callback("error", str(e)) - self._safe_remove_download(client, download_id, protocol, "polling exception") + # Extract download URL + download_url = get_preferred_download_url(prowlarr_result) + if not download_url: + status_callback("error", "No download URL available") return None - def _handle_completed_file( - self, - source_path: Path, - protocol: str, - task: DownloadTask, - status_callback: Callable[[str, Optional[str]], None], - ) -> Optional[str]: - """Handle a completed download and return its path. - - For external download clients (torrents/usenet), staging large payloads into TMP_DIR - is expensive (and can duplicate multi-GB files). Instead, return the client's - completed path and let the orchestrator perform any required transfer (copy/move/ - hardlink) directly from that source. - - Torrents also set ``task.original_download_path`` so the orchestrator can detect - seeding data and enable hardlinking when configured. - """ - try: - if protocol == "torrent": - task.original_download_path = str(source_path) - - logger.debug(f"Download complete, returning original path: {source_path}") - return str(source_path) - - except Exception as e: - logger.error(f"Failed to finalize completed download at {source_path}: {e}") - status_callback("error", f"Failed to finalize completed download: {e}") + # Determine protocol + protocol = get_protocol(prowlarr_result) + if protocol == "unknown": + status_callback("error", "Could not determine download protocol") return None + release_name = prowlarr_result.get("title") or task.title or "Unknown" + expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None + + return DownloadRequest( + url=download_url, + protocol=protocol, + release_name=release_name, + expected_hash=expected_hash, + ) + + def _on_download_complete(self, task: DownloadTask) -> None: + """Remove completed release from the Prowlarr cache.""" + remove_release(task.task_id) + def cancel(self, task_id: str) -> bool: """Cancel download and clean up cache. Primary cancellation is via cancel_flag.""" logger.debug(f"Cancel requested for Prowlarr task: {task_id}") - # Remove from cache if present remove_release(task_id) - return True + return super().cancel(task_id) diff --git a/shelfmark/release_sources/prowlarr/settings.py b/shelfmark/release_sources/prowlarr/settings.py index f7e21de3..9e2ae81c 100644 --- a/shelfmark/release_sources/prowlarr/settings.py +++ b/shelfmark/release_sources/prowlarr/settings.py @@ -1,30 +1,22 @@ -""" -Prowlarr settings registration. - -Registers Prowlarr settings as a group with multiple tabs: -- Configuration: Prowlarr connection settings + indexer selection -- Download Clients: Torrent and usenet client settings -""" +"""Prowlarr settings registration.""" from typing import Any, Dict, List, Optional from shelfmark.core.settings_registry import ( - register_group, register_settings, CheckboxField, HeadingField, TextField, PasswordField, ActionButton, - SelectField, MultiSelectField, - TagListField, ) from shelfmark.core.utils import normalize_http_url # ==================== Dynamic Options Loaders ==================== + def _get_indexer_options() -> List[Dict[str, str]]: """ Fetch available indexers from Prowlarr for the multi-select field. @@ -76,15 +68,14 @@ def _get_indexer_options() -> List[Dict[str, str]]: return [] -# ==================== Test Connection Callbacks ==================== +# ==================== Test Connection Callback ==================== + def _test_prowlarr_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Test the Prowlarr connection using current form values.""" from shelfmark.core.config import config - from shelfmark.core.logger import setup_logger from shelfmark.release_sources.prowlarr.api import ProwlarrClient - logger = setup_logger(__name__) current_values = current_values or {} raw_url = current_values.get("PROWLARR_URL") or config.get("PROWLARR_URL", "") @@ -107,321 +98,14 @@ def _test_prowlarr_connection(current_values: Optional[Dict[str, Any]] = None) - return {"success": False, "message": f"Connection failed: {str(e)}"} -def _test_qbittorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """Test the qBittorrent connection using current form values.""" - from shelfmark.core.config import config - - current_values = current_values or {} - - raw_url = current_values.get("QBITTORRENT_URL") or config.get("QBITTORRENT_URL", "") - username = current_values.get("QBITTORRENT_USERNAME") or config.get("QBITTORRENT_USERNAME", "") - password = current_values.get("QBITTORRENT_PASSWORD") or config.get("QBITTORRENT_PASSWORD", "") - - if not raw_url: - return {"success": False, "message": "qBittorrent URL is required"} - - try: - from qbittorrentapi import Client - - url = normalize_http_url(raw_url) - if not url: - return {"success": False, "message": "qBittorrent URL is invalid"} - - client = Client(host=url, username=username, password=password) - client.auth_log_in() - api_version = client.app.web_api_version - return {"success": True, "message": f"Connected to qBittorrent (API v{api_version})"} - except ImportError: - return {"success": False, "message": "qbittorrent-api package not installed"} - except Exception as e: - return {"success": False, "message": f"Connection failed: {str(e)}"} - - -def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """Test the Transmission connection using current form values.""" - from shelfmark.core.config import config - from shelfmark.release_sources.prowlarr.clients.torrent_utils import ( - parse_transmission_url, - ) - - current_values = current_values or {} - - raw_url = current_values.get("TRANSMISSION_URL") or config.get("TRANSMISSION_URL", "") - username = current_values.get("TRANSMISSION_USERNAME") or config.get("TRANSMISSION_USERNAME", "") - password = current_values.get("TRANSMISSION_PASSWORD") or config.get("TRANSMISSION_PASSWORD", "") - - if not raw_url: - return {"success": False, "message": "Transmission URL is required"} - - url = normalize_http_url(raw_url) - if not url: - return {"success": False, "message": "Transmission URL is invalid"} - - try: - from transmission_rpc import Client - - # Parse URL to extract host, port, and path - protocol, host, port, path = parse_transmission_url(url) - - client_kwargs = { - "host": host, - "port": port, - "path": path, - "username": username if username else None, - "password": password if password else None, - "protocol": protocol, - } - try: - client = Client(**client_kwargs) - except TypeError as e: - if "protocol" not in str(e): - raise - client_kwargs.pop("protocol", None) - client = Client(**client_kwargs) - if protocol == "https" and hasattr(client, "protocol"): - try: - setattr(client, "protocol", protocol) - except Exception: - pass - session = client.get_session() - version = session.version - return {"success": True, "message": f"Connected to Transmission {version}"} - except ImportError: - return {"success": False, "message": "transmission-rpc package not installed"} - except Exception as e: - return {"success": False, "message": f"Connection failed: {str(e)}"} - - -def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """Test Deluge Web UI JSON-RPC connection using current form values.""" - from urllib.parse import urlparse - - import requests - from shelfmark.core.config import config - - current_values = current_values or {} - - raw_host = current_values.get("DELUGE_HOST") or config.get("DELUGE_HOST", "localhost") - raw_port = current_values.get("DELUGE_PORT") or config.get("DELUGE_PORT", "8112") - password = current_values.get("DELUGE_PASSWORD") or config.get("DELUGE_PASSWORD", "") - - if not raw_host: - return {"success": False, "message": "Deluge host is required"} - if not password: - return {"success": False, "message": "Deluge password is required"} - - raw_host = str(raw_host) - raw_host = normalize_http_url(raw_host, strip_trailing_slash=False) if raw_host else "" - if not raw_host: - return {"success": False, "message": "Deluge host is invalid"} - - raw_port = str(raw_port or "8112") - - scheme = "http" - base_path = "" - host = raw_host - port = int(raw_port) if raw_port.isdigit() else 8112 - - # Allow DELUGE_HOST to be a full URL (e.g. http://deluge:8112) - if raw_host.startswith(("http://", "https://")): - parsed = urlparse(raw_host) - scheme = parsed.scheme or "http" - host = parsed.hostname or "localhost" - if parsed.port is not None: - port = parsed.port - base_path = (parsed.path or "").rstrip("/") - else: - # Allow "host:port" in DELUGE_HOST for convenience. - if ":" in raw_host and raw_host.count(":") == 1: - host_part, port_part = raw_host.split(":", 1) - if host_part and port_part.isdigit(): - host = host_part - port = int(port_part) - - rpc_url = f"{scheme}://{host}:{port}{base_path}/json" - - def rpc_call(session: requests.Session, rpc_id: int, method: str, *params: Any) -> Any: - payload = {"id": rpc_id, "method": method, "params": list(params)} - resp = session.post(rpc_url, json=payload, timeout=15) - resp.raise_for_status() - data = resp.json() - if data.get("error"): - error = data["error"] - if isinstance(error, dict): - raise Exception(error.get("message") or str(error)) - raise Exception(str(error)) - return data.get("result") - - def get_daemon_version(session: requests.Session, rpc_id: int) -> Any: - try: - methods = rpc_call(session, rpc_id, "system.listMethods") - if isinstance(methods, list) and "daemon.get_version" in methods: - return rpc_call(session, rpc_id + 1, "daemon.get_version") - except Exception: - # Fall back to daemon.info to preserve existing behavior. - pass - - return rpc_call(session, rpc_id + 1, "daemon.info") - - try: - session = requests.Session() - - if rpc_call(session, 1, "auth.login", password) is not True: - return {"success": False, "message": "Deluge Web UI authentication failed"} - - if rpc_call(session, 2, "web.connected") is not True: - hosts = rpc_call(session, 3, "web.get_hosts") or [] - if not hosts: - return { - "success": False, - "message": "Deluge Web UI isn't connected to Deluge core (no hosts configured). Add/connect a daemon in Deluge Web UI → Connection Manager.", - } - - host_id = hosts[0][0] - for entry in hosts: - if isinstance(entry, list) and len(entry) >= 2 and entry[1] in {"127.0.0.1", "localhost"}: - host_id = entry[0] - break - - rpc_call(session, 4, "web.connect", host_id) - - if rpc_call(session, 5, "web.connected") is not True: - return { - "success": False, - "message": "Deluge Web UI couldn't connect to Deluge core. Check Deluge Web UI → Connection Manager.", - } - - version = get_daemon_version(session, 6) - return {"success": True, "message": f"Connected to Deluge {version}"} - - except requests.exceptions.ConnectionError: - return {"success": False, "message": "Could not connect to Deluge Web UI"} - except requests.exceptions.Timeout: - return {"success": False, "message": "Connection timed out"} - except Exception as e: - return {"success": False, "message": f"Connection failed: {str(e)}"} - - -def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """Test the rTorrent connection using current form values.""" - from shelfmark.core.config import config - from urllib.parse import urlparse - from xmlrpc.client import ServerProxy - - current_values = current_values or {} - - raw_url = current_values.get("RTORRENT_URL") or config.get("RTORRENT_URL", "") - username = current_values.get("RTORRENT_USERNAME") or config.get("RTORRENT_USERNAME", "") - password = current_values.get("RTORRENT_PASSWORD") or config.get("RTORRENT_PASSWORD", "") - - if not raw_url: - return {"success": False, "message": "rTorrent URL is required"} - - url = normalize_http_url(raw_url) - if not url: - return {"success": False, "message": "rTorrent URL is invalid"} - - try: - # Add HTTP auth to URL if credentials provided - if username and password: - parsed = urlparse(url) - url = f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}" - - rpc = ServerProxy(url.rstrip("/")) - version = rpc.system.client_version() - return {"success": True, "message": f"Connected to rTorrent {version}"} - except Exception as e: - return {"success": False, "message": f"Connection failed: {str(e)}"} - - -def _test_nzbget_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """Test the NZBGet connection using current form values.""" - import requests - from shelfmark.core.config import config - - current_values = current_values or {} - - raw_url = current_values.get("NZBGET_URL") or config.get("NZBGET_URL", "") - username = current_values.get("NZBGET_USERNAME") or config.get("NZBGET_USERNAME", "nzbget") - password = current_values.get("NZBGET_PASSWORD") or config.get("NZBGET_PASSWORD", "") - - if not raw_url: - return {"success": False, "message": "NZBGet URL is required"} - - url = normalize_http_url(raw_url) - if not url: - return {"success": False, "message": "NZBGet URL is invalid"} - - try: - rpc_url = f"{url.rstrip('/')}/jsonrpc" - payload = {"jsonrpc": "2.0", "method": "status", "params": [], "id": 1} - response = requests.post(rpc_url, json=payload, auth=(username, password), timeout=30) - response.raise_for_status() - result = response.json() - if "error" in result and result["error"]: - raise Exception(result["error"].get("message", "RPC error")) - version = result.get("result", {}).get("Version", "unknown") - return {"success": True, "message": f"Connected to NZBGet {version}"} - except requests.exceptions.ConnectionError: - return {"success": False, "message": "Could not connect to NZBGet"} - except requests.exceptions.Timeout: - return {"success": False, "message": "Connection timed out"} - except Exception as e: - return {"success": False, "message": f"Connection failed: {str(e)}"} - - -def _test_sabnzbd_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """Test the SABnzbd connection using current form values.""" - import requests - from shelfmark.core.config import config - - current_values = current_values or {} - - raw_url = current_values.get("SABNZBD_URL") or config.get("SABNZBD_URL", "") - api_key = current_values.get("SABNZBD_API_KEY") or config.get("SABNZBD_API_KEY", "") - - if not raw_url: - return {"success": False, "message": "SABnzbd URL is required"} - - url = normalize_http_url(raw_url) - if not url: - return {"success": False, "message": "SABnzbd URL is invalid"} - if not api_key: - return {"success": False, "message": "API key is required"} - - try: - api_url = f"{url.rstrip('/')}/api" - params = {"apikey": api_key, "mode": "version", "output": "json"} - response = requests.get(api_url, params=params, timeout=30) - response.raise_for_status() - result = response.json() - version = result.get("version", "unknown") - return {"success": True, "message": f"Connected to SABnzbd {version}"} - except requests.exceptions.ConnectionError: - return {"success": False, "message": "Could not connect to SABnzbd"} - except requests.exceptions.Timeout: - return {"success": False, "message": "Connection timed out"} - except Exception as e: - return {"success": False, "message": f"Connection failed: {str(e)}"} - - -# ==================== Register Group ==================== - -register_group( - name="prowlarr", - display_name="Prowlarr", - icon="download", - order=40, -) - - # ==================== Configuration Tab ==================== + @register_settings( name="prowlarr_config", - display_name="Configuration", + display_name="Prowlarr", + icon="download", order=41, - group="prowlarr", ) def prowlarr_config_settings(): """Prowlarr connection and indexer settings.""" @@ -478,346 +162,3 @@ def prowlarr_config_settings(): show_when={"field": "PROWLARR_ENABLED", "value": True}, ), ] - - -# ==================== Download Clients Tab ==================== - -@register_settings( - name="prowlarr_clients", - display_name="Download Clients", - order=42, - group="prowlarr", -) -def prowlarr_clients_settings(): - """Download client settings for Prowlarr.""" - return [ - # --- Torrent Client Selection --- - HeadingField( - key="torrent_heading", - title="Torrent Client", - description="Select and configure a torrent client for downloading torrents from Prowlarr.", - ), - SelectField( - key="PROWLARR_TORRENT_CLIENT", - label="Torrent Client", - description="Choose which torrent client to use", - options=[ - {"value": "", "label": "None"}, - {"value": "qbittorrent", "label": "qBittorrent"}, - {"value": "transmission", "label": "Transmission"}, - {"value": "deluge", "label": "Deluge"}, - {"value": "rtorrent", "label": "rTorrent"}, - ], - default="", - ), - - # --- qBittorrent Settings --- - TextField( - key="QBITTORRENT_URL", - label="qBittorrent URL", - description="Web UI URL of your qBittorrent instance", - placeholder="http://qbittorrent:8080", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, - ), - TextField( - key="QBITTORRENT_USERNAME", - label="Username", - description="qBittorrent Web UI username", - placeholder="admin", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, - ), - PasswordField( - key="QBITTORRENT_PASSWORD", - label="Password", - description="qBittorrent Web UI password", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, - ), - ActionButton( - key="test_qbittorrent", - label="Test Connection", - description="Verify your qBittorrent configuration", - style="primary", - callback=_test_qbittorrent_connection, - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, - ), - TextField( - key="QBITTORRENT_CATEGORY", - label="Book Category", - description="Category to assign to book downloads in qBittorrent", - placeholder="books", - default="books", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, - ), - TextField( - key="QBITTORRENT_CATEGORY_AUDIOBOOK", - label="Audiobook Category", - description="Category for audiobook downloads. Leave empty to use the book category.", - placeholder="", - default="", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, - ), - TagListField( - key="QBITTORRENT_TAG", - label="Tags", - description="Tag(s) to assign to qBittorrent downloads. Leave empty for no tags.", - placeholder="", - default=[], - normalize_urls=False, - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, - ), - - # --- Transmission Settings --- - TextField( - key="TRANSMISSION_URL", - label="Transmission URL", - description="URL of your Transmission instance (use https:// for TLS)", - placeholder="http://transmission:9091", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, - ), - TextField( - key="TRANSMISSION_USERNAME", - label="Username", - description="Transmission RPC username (if authentication enabled)", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, - ), - PasswordField( - key="TRANSMISSION_PASSWORD", - label="Password", - description="Transmission RPC password", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, - ), - ActionButton( - key="test_transmission", - label="Test Connection", - description="Verify your Transmission configuration", - style="primary", - callback=_test_transmission_connection, - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, - ), - TextField( - key="TRANSMISSION_CATEGORY", - label="Book Label", - description="Label to assign to book downloads in Transmission", - placeholder="books", - default="books", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, - ), - TextField( - key="TRANSMISSION_CATEGORY_AUDIOBOOK", - label="Audiobook Label", - description="Label for audiobook downloads. Leave empty to use the book label.", - placeholder="", - default="", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, - ), - - # --- Deluge Settings --- - TextField( - key="DELUGE_HOST", - label="Deluge Web UI Host/URL", - description="Hostname/IP or full URL of your Deluge Web UI (deluge-web)", - placeholder="http://deluge:8112", - default="localhost", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, - ), - TextField( - key="DELUGE_PORT", - label="Deluge Web UI Port", - description="Deluge Web UI port (default: 8112)", - placeholder="8112", - default="8112", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, - ), - PasswordField( - key="DELUGE_PASSWORD", - label="Password", - description="Deluge Web UI password (default: deluge)", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, - ), - ActionButton( - key="test_deluge", - label="Test Connection", - description="Verify your Deluge configuration", - style="primary", - callback=_test_deluge_connection, - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, - ), - TextField( - key="DELUGE_CATEGORY", - label="Book Label", - description="Label to assign to book downloads in Deluge", - placeholder="books", - default="books", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, - ), - TextField( - key="DELUGE_CATEGORY_AUDIOBOOK", - label="Audiobook Label", - description="Label for audiobook downloads. Leave empty to use the book label.", - placeholder="", - default="", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, - ), - - # --- rTorrent Settings --- - TextField( - key="RTORRENT_URL", - label="rTorrent URL", - description="XML-RPC URL of your rTorrent instance", - placeholder="http://rtorrent:6881/RPC2", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, - ), - TextField( - key="RTORRENT_USERNAME", - label="Username", - description="HTTP Basic auth username (if authentication enabled)", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, - ), - PasswordField( - key="RTORRENT_PASSWORD", - label="Password", - description="HTTP Basic auth password", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, - ), - ActionButton( - key="test_rtorrent", - label="Test Connection", - description="Verify your rTorrent configuration", - style="primary", - callback=_test_rtorrent_connection, - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, - ), - TextField( - key="RTORRENT_LABEL", - label="Book Label", - description="Label to assign to book downloads in rTorrent", - placeholder="cwabd", - default="cwabd", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, - ), - TextField( - key="RTORRENT_DOWNLOAD_DIR", - label="Download Directory", - description="Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified)", - placeholder="/downloads", - show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, - ), - # Note: Torrent client download path must be mounted identically in both containers. - # Torrents are always copied (not moved) to preserve seeding capability. - - # --- Usenet Client Selection --- - HeadingField( - key="usenet_heading", - title="Usenet Client", - description="Select and configure a usenet client for downloading NZBs from Prowlarr.", - ), - SelectField( - key="PROWLARR_USENET_CLIENT", - label="Usenet Client", - description="Choose which usenet client to use", - options=[ - {"value": "", "label": "None"}, - {"value": "nzbget", "label": "NZBGet"}, - {"value": "sabnzbd", "label": "SABnzbd"}, - ], - default="", - ), - - # --- NZBGet Settings --- - TextField( - key="NZBGET_URL", - label="NZBGet URL", - description="URL of your NZBGet instance", - placeholder="http://nzbget:6789", - show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, - ), - TextField( - key="NZBGET_USERNAME", - label="Username", - description="NZBGet control username", - placeholder="nzbget", - default="nzbget", - show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, - ), - PasswordField( - key="NZBGET_PASSWORD", - label="Password", - description="NZBGet control password", - show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, - ), - ActionButton( - key="test_nzbget", - label="Test Connection", - description="Verify your NZBGet configuration", - style="primary", - callback=_test_nzbget_connection, - show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, - ), - TextField( - key="NZBGET_CATEGORY", - label="Book Category", - description="Category to assign to book downloads in NZBGet", - placeholder="Books", - default="Books", - show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, - ), - TextField( - key="NZBGET_CATEGORY_AUDIOBOOK", - label="Audiobook Category", - description="Category for audiobook downloads. Leave empty to use the book category.", - placeholder="", - default="", - show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, - ), - - # --- SABnzbd Settings --- - TextField( - key="SABNZBD_URL", - label="SABnzbd URL", - description="URL of your SABnzbd instance", - placeholder="http://sabnzbd:8080", - show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, - ), - PasswordField( - key="SABNZBD_API_KEY", - label="API Key", - description="Found in SABnzbd: Config > General > API Key", - show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, - ), - ActionButton( - key="test_sabnzbd", - label="Test Connection", - description="Verify your SABnzbd configuration", - style="primary", - callback=_test_sabnzbd_connection, - show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, - ), - TextField( - key="SABNZBD_CATEGORY", - label="Book Category", - description="Category to assign to book downloads in SABnzbd", - placeholder="books", - default="books", - show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, - ), - TextField( - key="SABNZBD_CATEGORY_AUDIOBOOK", - label="Audiobook Category", - description="Category for audiobook downloads. Leave empty to use the book category.", - placeholder="", - default="", - show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, - ), - # Note: Usenet client download path must be mounted identically in both containers. - SelectField( - key="PROWLARR_USENET_ACTION", - label="NZB Completion Action", - description="Move deletes the job from your usenet client after import; Copy keeps it in the client", - options=[ - {"value": "move", "label": "Move"}, - {"value": "copy", "label": "Copy"}, - ], - default="move", - show_when={"field": "PROWLARR_USENET_CLIENT", "notEmpty": True}, - ), - ] diff --git a/src/frontend/src/components/ReleaseCell.tsx b/src/frontend/src/components/ReleaseCell.tsx index 07641474..50bd6656 100644 --- a/src/frontend/src/components/ReleaseCell.tsx +++ b/src/frontend/src/components/ReleaseCell.tsx @@ -164,6 +164,8 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }: const extra = release.extra as Record | undefined; const torznabAttrs = extra?.torznab_attrs as Record | undefined; const publishDate = extra?.publish_date as string | undefined; + const postedDate = extra?.posted_date as string | undefined; + const bitrate = extra?.bitrate as string | undefined; // Helper to format age in days const formatRelativeTime = (dateStr: string): string | null => { @@ -191,6 +193,19 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }: } } + if (postedDate) { + const value = postedDate.trim(); + const relativeTime = formatRelativeTime(value); + rows.push({ label: 'Posted', value: relativeTime ?? value }); + } + + if (bitrate) { + const bitrateValue = bitrate.trim(); + if (bitrateValue) { + rows.push({ label: 'Bitrate', value: bitrateValue }); + } + } + // Add torznab attributes if available (MAM, etc.) if (torznabAttrs && Object.keys(torznabAttrs).length > 0) { const displayAttrs: Array<{ key: string; label: string }> = [ diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index 52a43df5..26c4f197 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -149,6 +149,167 @@ function sortReleases( }); } +function normalizeMatchText(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function collectNormalizedStrings(values: Array): string[] { + const seen = new Set(); + const normalizedValues: string[] = []; + + for (const value of values) { + if (!value) continue; + const normalized = normalizeMatchText(value); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + normalizedValues.push(normalized); + } + + return normalizedValues; +} + +function getLocalizedTitleValues(raw: unknown): string[] { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return []; + } + + const values: string[] = []; + for (const value of Object.values(raw as Record)) { + if (typeof value === 'string' && value.trim()) { + values.push(value); + } + } + return values; +} + +function splitAuthorString(author: string): string[] { + return author + .split(',') + .map((part) => part.trim()) + .filter(Boolean); +} + +function getBookTitleCandidates( + uiBook: Book | null, + responseBook: ReleasesResponse['book'] | undefined +): string[] { + const responseBookRecord = responseBook as Record | undefined; + const uiBookRecord = uiBook as Record | undefined; + return collectNormalizedStrings([ + responseBook?.search_title, + responseBook?.title, + ...getLocalizedTitleValues(responseBookRecord?.titles_by_language), + uiBook?.search_title, + uiBook?.title, + ...getLocalizedTitleValues(uiBookRecord?.titles_by_language), + ]); +} + +function getBookAuthorCandidates( + uiBook: Book | null, + responseBook: ReleasesResponse['book'] | undefined +): string[] { + const responseAuthors = responseBook?.authors ?? []; + const uiAuthors = uiBook?.authors ?? []; + const uiAuthorParts = uiBook?.author ? splitAuthorString(uiBook.author) : []; + return collectNormalizedStrings([ + responseBook?.search_author, + ...responseAuthors, + uiBook?.search_author, + ...uiAuthors, + ...uiAuthorParts, + ]); +} + +function getReleaseAuthorForMatch(release: Release): string | null { + const rawAuthor = release.extra?.author; + if (typeof rawAuthor !== 'string') { + return null; + } + + const normalized = normalizeMatchText(rawAuthor); + return normalized || null; +} + +function hasExactAuthorMatch(release: Release, authorCandidates: string[]): boolean { + if (authorCandidates.length === 0) { + return false; + } + + const releaseAuthor = getReleaseAuthorForMatch(release); + if (!releaseAuthor) { + return false; + } + + return authorCandidates.includes(releaseAuthor); +} + +function getTitleMatchScore(title: string, titleCandidate: string): number { + const normalizedTitle = normalizeMatchText(title); + if (!normalizedTitle || !titleCandidate) { + return 0; + } + + if (normalizedTitle === titleCandidate) { + return 10000; + } + + let score = 0; + + if (normalizedTitle.startsWith(titleCandidate)) { + score += 6000; + } + + if (normalizedTitle.includes(titleCandidate)) { + score += 3000; + } + + const candidateTokens = titleCandidate.split(' ').filter((token) => token.length > 1); + if (candidateTokens.length > 0) { + const titleTokens = new Set(normalizedTitle.split(' ')); + const matchedTokens = candidateTokens.filter((token) => ( + titleTokens.has(token) || normalizedTitle.includes(token) + )).length; + score += Math.round((matchedTokens / candidateTokens.length) * 2500); + } + + // Prefer closer-length titles when match quality is otherwise similar. + score -= Math.abs(normalizedTitle.length - titleCandidate.length); + + return score; +} + +function sortReleasesByBookMatch( + releases: Release[], + titleCandidates: string[], + authorCandidates: string[] +): Release[] { + if (titleCandidates.length === 0) { + return releases; + } + + return releases + .map((release, index) => ({ + release, + index, + score: titleCandidates.reduce((best, candidate) => ( + Math.max(best, getTitleMatchScore(release.title, candidate)) + ), 0) + (hasExactAuthorMatch(release, authorCandidates) ? 1500 : 0), + })) + .sort((a, b) => { + const scoreDiff = b.score - a.score; + if (scoreDiff !== 0) { + return scoreDiff; + } + return a.index - b.index; + }) + .map(({ release }) => release); +} + // Default column configuration (fallback when backend doesn't provide one) const DEFAULT_COLUMN_CONFIG: ReleaseColumnConfig = { columns: [ @@ -680,7 +841,7 @@ export const ReleaseModal = ({ const [showManualQuery, setShowManualQuery] = useState(false); // Sort state - keyed by source name, persisted to localStorage - // null means "Default" (backend order), undefined means "not set yet" + // null means "Default" (best title match), undefined means "not set yet" const [sortBySource, setSortBySource] = useState>({}); // Description expansion @@ -1130,7 +1291,7 @@ export const ReleaseModal = ({ return columnConfig.columns.filter(col => col.sortable) || []; }, [columnConfig]); - // Get current sort state for active tab (from state, localStorage, or default to null = backend order) + // Get current sort state for active tab (from state, localStorage, or default to null = best match) const currentSort = useMemo((): SortState | null => { // Check state first - explicit null means "Default" was selected if (activeTab in sortBySource) { @@ -1145,14 +1306,14 @@ export const ReleaseModal = ({ return saved; } } - // Default to null (backend order / no client-side sorting) + // Default to null (best-match sorting) return null; }, [activeTab, sortBySource, sortableColumns]); - // Handle sort change - null means "Default" (backend order), otherwise toggle direction or set new column + // Handle sort change - null means "Default" (best title match), otherwise toggle direction or set new column const handleSortChange = useCallback((sortKey: string | null, column: ColumnSchema | null) => { if (sortKey === null) { - // "Default" selected - clear client-side sorting + // "Default" selected - use best-match sorting setSortBySource(prev => { const next = { ...prev }; delete next[activeTab]; @@ -1225,13 +1386,18 @@ export const ReleaseModal = ({ return true; }); - // Then, sort if we have a current sort and sortable columns + // Then, sort by explicit column, or default to book-title relevance with exact author boost if (currentSort && sortableColumns.length > 0) { filtered = sortReleases(filtered, currentSort.key, currentSort.direction); + } else { + const responseBook = releasesBySource[activeTab]?.book; + const titleCandidates = getBookTitleCandidates(book, responseBook); + const authorCandidates = getBookAuthorCandidates(book, responseBook); + filtered = sortReleasesByBookMatch(filtered, titleCandidates, authorCandidates); } return filtered; - }, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, languageNormalizer, indexerFilter, currentSort, sortableColumns, columnConfig]); + }, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, languageNormalizer, indexerFilter, currentSort, sortableColumns, columnConfig, book]); // Pre-compute display field lookups to avoid repeated .find() calls in JSX const displayFields = useMemo(() => { @@ -1632,7 +1798,7 @@ export const ReleaseModal = ({ > {({ close }) => (
- {/* Default option - no client-side sorting */} + {/* Default option - book-title best match */}