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 */}