mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-02 15:05:46 +01:00
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
This commit is contained in:
+7
-7
@@ -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
|
||||
@@ -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
|
||||
+2
-2
@@ -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,
|
||||
)
|
||||
|
||||
+1
-1
@@ -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,
|
||||
+2
-2
@@ -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,
|
||||
)
|
||||
|
||||
+2
-2
@@ -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,
|
||||
)
|
||||
|
||||
+2
-1
@@ -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 {}
|
||||
@@ -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},
|
||||
),
|
||||
]
|
||||
+1
@@ -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
|
||||
+2
-2
@@ -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,
|
||||
)
|
||||
+23
-12
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <td>Info Hash</td> 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
|
||||
|
||||
@@ -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'.",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -164,6 +164,8 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
|
||||
const extra = release.extra as Record<string, unknown> | undefined;
|
||||
const torznabAttrs = extra?.torznab_attrs as Record<string, string> | 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 }> = [
|
||||
|
||||
@@ -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 | null | undefined>): string[] {
|
||||
const seen = new Set<string>();
|
||||
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<string, unknown>)) {
|
||||
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<string, unknown> | undefined;
|
||||
const uiBookRecord = uiBook as Record<string, unknown> | 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<boolean>(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<Record<string, SortState | null>>({});
|
||||
|
||||
// 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 }) => (
|
||||
<div className="py-1">
|
||||
{/* Default option - no client-side sorting */}
|
||||
{/* Default option - book-title best match */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1644,7 +1810,7 @@ export const ReleaseModal = ({
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span>Default</span>
|
||||
<span>Best Match (Default)</span>
|
||||
{!currentSort && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
|
||||
@@ -115,6 +115,7 @@ type SidebarItem =
|
||||
const SECTION_HEADERS: { beforeGroup?: string; beforeTab?: string; label: string }[] = [
|
||||
{ beforeGroup: 'direct_download', label: 'Release Sources' },
|
||||
{ beforeTab: 'hardcover', label: 'Metadata Providers' },
|
||||
{ beforeTab: 'prowlarr_clients', label: 'Download Clients' },
|
||||
];
|
||||
|
||||
export const SettingsSidebar = ({
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface Book {
|
||||
subtitle?: string;
|
||||
search_title?: string;
|
||||
search_author?: string;
|
||||
authors?: string[];
|
||||
titles_by_language?: Record<string, string>;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
@@ -363,6 +365,7 @@ export interface ReleasesResponse {
|
||||
search_author?: string;
|
||||
search_title?: string;
|
||||
authors?: string[];
|
||||
titles_by_language?: Record<string, string>;
|
||||
isbn_10?: string;
|
||||
isbn_13?: string;
|
||||
cover_url?: string;
|
||||
|
||||
@@ -29,7 +29,9 @@ export interface MetadataBookData {
|
||||
series_position?: number;
|
||||
series_count?: number;
|
||||
subtitle?: string;
|
||||
search_title?: string;
|
||||
search_author?: string;
|
||||
titles_by_language?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,7 +60,10 @@ export function transformMetadataToBook(data: MetadataBookData): Book {
|
||||
series_position: data.series_position,
|
||||
series_count: data.series_count,
|
||||
subtitle: data.subtitle,
|
||||
search_title: data.search_title,
|
||||
search_author: data.search_author,
|
||||
authors: data.authors,
|
||||
titles_by_language: data.titles_by_language,
|
||||
info: {
|
||||
...(data.isbn_13 && { ISBN: data.isbn_13 }),
|
||||
...(data.isbn_10 && !data.isbn_13 && { ISBN: data.isbn_10 }),
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
Tests for AudiobookBay download handler.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.release_sources.audiobookbay.handler import AudiobookBayHandler
|
||||
from shelfmark.release_sources.prowlarr.clients import (
|
||||
from shelfmark.download.clients import (
|
||||
DownloadStatus,
|
||||
DownloadState,
|
||||
)
|
||||
@@ -64,21 +65,22 @@ class TestAudiobookBayHandlerDownload:
|
||||
)
|
||||
cancel_flag = Event()
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
result = handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
assert result is None # Torrents don't return path immediately
|
||||
with patch.object(AudiobookBayHandler, "_poll_and_complete", return_value=None) as mock_poll:
|
||||
result = handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
mock_extract_magnet.assert_called_once_with(
|
||||
"https://audiobookbay.lu/abss/test-book/",
|
||||
"audiobookbay.lu"
|
||||
)
|
||||
mock_client.add_download.assert_called_once()
|
||||
assert "downloading" in recorder.statuses
|
||||
mock_poll.assert_called_once()
|
||||
assert "resolving" in recorder.statuses
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link')
|
||||
@patch('shelfmark.release_sources.audiobookbay.handler.get_client')
|
||||
@@ -111,12 +113,17 @@ class TestAudiobookBayHandlerDownload:
|
||||
cancel_flag = Event()
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
result = handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
with patch.object(
|
||||
AudiobookBayHandler,
|
||||
"_wait_for_completed_path",
|
||||
return_value=(Path("/path/to/book.m4b"), None),
|
||||
):
|
||||
result = handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
assert result == "/path/to/book.m4b"
|
||||
mock_client.add_download.assert_not_called()
|
||||
@@ -151,16 +158,18 @@ class TestAudiobookBayHandlerDownload:
|
||||
cancel_flag = Event()
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
result = handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
with patch.object(AudiobookBayHandler, "_poll_and_complete", return_value=None) as mock_poll:
|
||||
result = handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert "downloading" in recorder.statuses
|
||||
mock_client.add_download.assert_not_called()
|
||||
mock_poll.assert_called_once()
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link')
|
||||
@patch('shelfmark.release_sources.audiobookbay.handler.get_client')
|
||||
@@ -203,13 +212,18 @@ class TestAudiobookBayHandlerDownload:
|
||||
)
|
||||
cancel_flag = Event()
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
result = handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
AudiobookBayHandler,
|
||||
"_wait_for_completed_path",
|
||||
return_value=(None, "Could not locate existing download path"),
|
||||
):
|
||||
result = handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert recorder.last_status == "error"
|
||||
@@ -353,12 +367,13 @@ class TestAudiobookBayHandlerCategory:
|
||||
cancel_flag = Event()
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
with patch.object(AudiobookBayHandler, "_poll_and_complete", return_value=None):
|
||||
handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
# Verify category was passed
|
||||
call_kwargs = mock_client.add_download.call_args.kwargs
|
||||
@@ -368,7 +383,7 @@ class TestAudiobookBayHandlerCategory:
|
||||
@patch('shelfmark.release_sources.audiobookbay.handler.get_client')
|
||||
@patch('shelfmark.release_sources.audiobookbay.handler.config.get')
|
||||
def test_category_selection_transmission_general(self, mock_config_get, mock_get_client, mock_extract_magnet):
|
||||
"""Test fallback to general category for Transmission."""
|
||||
"""Test Transmission audiobook category does not fall back to general category."""
|
||||
mock_extract_magnet.return_value = "magnet:?xt=urn:btih:abc123"
|
||||
|
||||
def config_get(key, default=""):
|
||||
@@ -394,16 +409,17 @@ class TestAudiobookBayHandlerCategory:
|
||||
cancel_flag = Event()
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
with patch.object(AudiobookBayHandler, "_poll_and_complete", return_value=None):
|
||||
handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
# Verify general category was used
|
||||
# Transmission audiobook downloads use only the audiobook category key.
|
||||
call_kwargs = mock_client.add_download.call_args.kwargs
|
||||
assert call_kwargs['category'] == "books"
|
||||
assert call_kwargs['category'] is None
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link')
|
||||
@patch('shelfmark.release_sources.audiobookbay.handler.get_client')
|
||||
@@ -430,12 +446,13 @@ class TestAudiobookBayHandlerCategory:
|
||||
cancel_flag = Event()
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
with patch.object(AudiobookBayHandler, "_poll_and_complete", return_value=None):
|
||||
handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
# Verify no category was passed
|
||||
call_kwargs = mock_client.add_download.call_args.kwargs
|
||||
|
||||
@@ -4,7 +4,6 @@ Tests for AudiobookBay scraper functions.
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from shelfmark.release_sources.audiobookbay import scraper
|
||||
|
||||
@@ -97,20 +96,15 @@ DETAIL_HTML_NO_TRACKERS = """
|
||||
class TestSearchAudiobookbay:
|
||||
"""Tests for the search_audiobookbay function."""
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.requests.get')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.network.get_proxies')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.config.get')
|
||||
def test_search_audiobookbay_success(self, mock_config_get, mock_get_proxies, mock_get):
|
||||
def test_search_audiobookbay_success(self, mock_config_get, mock_html_get):
|
||||
"""Test successful search with results."""
|
||||
mock_config_get.return_value = 1.0 # rate_limit_delay
|
||||
mock_get_proxies.return_value = {}
|
||||
|
||||
# Mock response
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.url = "https://audiobookbay.lu/page/1/?s=test+query&cat=undefined%2Cundefined"
|
||||
mock_response.text = SAMPLE_SEARCH_HTML
|
||||
mock_get.return_value = mock_response
|
||||
mock_html_get.return_value = (
|
||||
SAMPLE_SEARCH_HTML,
|
||||
"https://audiobookbay.lu/page/1/?s=test+query&cat=undefined%2Cundefined",
|
||||
)
|
||||
|
||||
results = scraper.search_audiobookbay("test query", max_pages=1, hostname="audiobookbay.lu")
|
||||
|
||||
@@ -120,115 +114,85 @@ class TestSearchAudiobookbay:
|
||||
assert results[0]['language'] == "English"
|
||||
assert results[0]['format'] == "M4B"
|
||||
assert results[0]['bitrate'] == "128 Kbps"
|
||||
assert results[0]['size'] == "500.00 MBs"
|
||||
assert results[0]['size'] == "500.00 MB"
|
||||
assert results[0]['posted_date'] == "01 Jan 2024"
|
||||
assert results[0]['cover'] == "https://example.com/cover.jpg"
|
||||
|
||||
assert results[1]['title'] == "Another Test Book - Another Author"
|
||||
assert results[1]['language'] == "Spanish"
|
||||
assert results[1]['format'] == "MP3"
|
||||
assert results[1]['size'] == "1.01 GBs"
|
||||
assert results[1]['size'] == "1.01 GB"
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.requests.get')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.network.get_proxies')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.config.get')
|
||||
def test_search_audiobookbay_pagination(self, mock_config_get, mock_get_proxies, mock_get):
|
||||
def test_search_audiobookbay_pagination(self, mock_config_get, mock_html_get):
|
||||
"""Test pagination through multiple pages."""
|
||||
mock_config_get.return_value = 0.0 # No delay for faster tests
|
||||
mock_get_proxies.return_value = {}
|
||||
|
||||
# First page response
|
||||
mock_response_page1 = Mock()
|
||||
mock_response_page1.status_code = 200
|
||||
mock_response_page1.url = "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined"
|
||||
mock_response_page1.text = SAMPLE_SEARCH_HTML
|
||||
|
||||
# Second page response (empty)
|
||||
mock_response_page2 = Mock()
|
||||
mock_response_page2.status_code = 200
|
||||
mock_response_page2.url = "https://audiobookbay.lu/page/2/?s=test&cat=undefined%2Cundefined"
|
||||
mock_response_page2.text = EMPTY_SEARCH_HTML
|
||||
|
||||
mock_get.side_effect = [mock_response_page1, mock_response_page2]
|
||||
mock_html_get.side_effect = [
|
||||
(SAMPLE_SEARCH_HTML, "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined"),
|
||||
(EMPTY_SEARCH_HTML, "https://audiobookbay.lu/page/2/?s=test&cat=undefined%2Cundefined"),
|
||||
]
|
||||
|
||||
results = scraper.search_audiobookbay("test", max_pages=2, hostname="audiobookbay.lu")
|
||||
|
||||
assert len(results) == 2 # Only from first page
|
||||
assert mock_get.call_count == 2
|
||||
assert mock_html_get.call_count == 2
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.requests.get')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.network.get_proxies')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.config.get')
|
||||
def test_search_audiobookbay_empty(self, mock_config_get, mock_get_proxies, mock_get):
|
||||
def test_search_audiobookbay_empty(self, mock_config_get, mock_html_get):
|
||||
"""Test search with no results."""
|
||||
mock_config_get.return_value = 1.0
|
||||
mock_get_proxies.return_value = {}
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.url = "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined"
|
||||
mock_response.text = EMPTY_SEARCH_HTML
|
||||
mock_get.return_value = mock_response
|
||||
mock_html_get.return_value = (
|
||||
EMPTY_SEARCH_HTML,
|
||||
"https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined",
|
||||
)
|
||||
|
||||
results = scraper.search_audiobookbay("test", max_pages=1, hostname="audiobookbay.lu")
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.requests.get')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.network.get_proxies')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.config.get')
|
||||
def test_search_audiobookbay_error_non_200(self, mock_config_get, mock_get_proxies, mock_get):
|
||||
def test_search_audiobookbay_error_non_200(self, mock_config_get, mock_html_get):
|
||||
"""Test error handling for non-200 status code."""
|
||||
mock_config_get.return_value = 1.0
|
||||
mock_get_proxies.return_value = {}
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 404
|
||||
mock_get.return_value = mock_response
|
||||
mock_html_get.return_value = ("", "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined")
|
||||
|
||||
results = scraper.search_audiobookbay("test", max_pages=1, hostname="audiobookbay.lu")
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.requests.get')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.network.get_proxies')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.config.get')
|
||||
def test_search_audiobookbay_redirect_to_homepage(self, mock_config_get, mock_get_proxies, mock_get):
|
||||
def test_search_audiobookbay_redirect_to_homepage(self, mock_config_get, mock_html_get):
|
||||
"""Test handling redirect to homepage (blocked/invalid search)."""
|
||||
mock_config_get.return_value = 1.0
|
||||
mock_get_proxies.return_value = {}
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.url = "https://audiobookbay.lu" # Redirected to homepage
|
||||
mock_response.text = EMPTY_SEARCH_HTML
|
||||
mock_get.return_value = mock_response
|
||||
mock_html_get.return_value = (
|
||||
EMPTY_SEARCH_HTML,
|
||||
"https://audiobookbay.lu", # Redirected to homepage
|
||||
)
|
||||
|
||||
results = scraper.search_audiobookbay("test", max_pages=1, hostname="audiobookbay.lu")
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.requests.get')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.network.get_proxies')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.config.get')
|
||||
def test_search_audiobookbay_request_exception(self, mock_config_get, mock_get_proxies, mock_get):
|
||||
def test_search_audiobookbay_request_exception(self, mock_config_get, mock_html_get):
|
||||
"""Test handling request exceptions."""
|
||||
mock_config_get.return_value = 1.0
|
||||
mock_get_proxies.return_value = {}
|
||||
|
||||
mock_get.side_effect = requests.exceptions.RequestException("Connection error")
|
||||
mock_html_get.return_value = ("", "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined")
|
||||
|
||||
results = scraper.search_audiobookbay("test", max_pages=1, hostname="audiobookbay.lu")
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.requests.get')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.network.get_proxies')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.config.get')
|
||||
def test_search_audiobookbay_relative_link(self, mock_config_get, mock_get_proxies, mock_get):
|
||||
def test_search_audiobookbay_relative_link(self, mock_config_get, mock_html_get):
|
||||
"""Test handling relative links in results."""
|
||||
mock_config_get.return_value = 1.0
|
||||
mock_get_proxies.return_value = {}
|
||||
|
||||
html_with_relative_link = """
|
||||
<div class="post">
|
||||
@@ -240,31 +204,65 @@ class TestSearchAudiobookbay:
|
||||
</div>
|
||||
"""
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.url = "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined"
|
||||
mock_response.text = html_with_relative_link
|
||||
mock_get.return_value = mock_response
|
||||
mock_html_get.return_value = (
|
||||
html_with_relative_link,
|
||||
"https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined",
|
||||
)
|
||||
|
||||
results = scraper.search_audiobookbay("test", max_pages=1, hostname="audiobookbay.lu")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]['link'] == "https://audiobookbay.lu/abss/relative-link/"
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.config.get')
|
||||
def test_search_audiobookbay_exact_phrase_query(self, mock_config_get, mock_html_get):
|
||||
"""Test exact phrase wrapping and encoding in search URL."""
|
||||
mock_config_get.return_value = 0.0
|
||||
mock_html_get.return_value = (
|
||||
SAMPLE_SEARCH_HTML,
|
||||
"https://audiobookbay.lu/page/1/?s=%22test+query%22",
|
||||
)
|
||||
|
||||
results = scraper.search_audiobookbay(
|
||||
"test query",
|
||||
max_pages=1,
|
||||
hostname="audiobookbay.lu",
|
||||
exact_phrase=True,
|
||||
)
|
||||
|
||||
assert len(results) == 2
|
||||
requested_url = mock_html_get.call_args.args[0]
|
||||
assert "s=%22test+query%22" in requested_url
|
||||
assert "cat=undefined%2Cundefined" not in requested_url
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.config.get')
|
||||
def test_search_audiobookbay_legacy_category_fallback(self, mock_config_get, mock_html_get):
|
||||
"""Test fallback to legacy category query when primary search request fails."""
|
||||
mock_config_get.return_value = 0.0
|
||||
mock_html_get.side_effect = [
|
||||
("", "https://audiobookbay.lu/page/1/?s=test"), # Primary fetch failed
|
||||
(SAMPLE_SEARCH_HTML, "https://audiobookbay.lu/page/1/?s=test&cat=undefined%2Cundefined"),
|
||||
]
|
||||
|
||||
results = scraper.search_audiobookbay("test", max_pages=1, hostname="audiobookbay.lu")
|
||||
|
||||
assert len(results) == 2
|
||||
assert mock_html_get.call_count == 2
|
||||
first_url = mock_html_get.call_args_list[0].args[0]
|
||||
second_url = mock_html_get.call_args_list[1].args[0]
|
||||
assert "cat=undefined%2Cundefined" not in first_url
|
||||
assert "cat=undefined%2Cundefined" in second_url
|
||||
|
||||
|
||||
class TestExtractMagnetLink:
|
||||
"""Tests for the extract_magnet_link function."""
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.requests.get')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.network.get_proxies')
|
||||
def test_extract_magnet_link_success(self, mock_get_proxies, mock_get):
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
def test_extract_magnet_link_success(self, mock_html_get):
|
||||
"""Test successful magnet link extraction."""
|
||||
mock_get_proxies.return_value = {}
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.text = SAMPLE_DETAIL_HTML
|
||||
mock_get.return_value = mock_response
|
||||
mock_html_get.return_value = SAMPLE_DETAIL_HTML
|
||||
|
||||
magnet_link = scraper.extract_magnet_link(
|
||||
"https://audiobookbay.lu/abss/test-book/",
|
||||
@@ -277,16 +275,10 @@ class TestExtractMagnetLink:
|
||||
assert "udp%3A//tracker.openbittorrent.com%3A80" in magnet_link
|
||||
assert "http%3A//tracker.example.com%3A8080" in magnet_link
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.requests.get')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.network.get_proxies')
|
||||
def test_extract_magnet_link_fallback(self, mock_get_proxies, mock_get):
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
def test_extract_magnet_link_fallback(self, mock_html_get):
|
||||
"""Test fallback to default trackers when none found."""
|
||||
mock_get_proxies.return_value = {}
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.text = DETAIL_HTML_NO_TRACKERS
|
||||
mock_get.return_value = mock_response
|
||||
mock_html_get.return_value = DETAIL_HTML_NO_TRACKERS
|
||||
|
||||
magnet_link = scraper.extract_magnet_link(
|
||||
"https://audiobookbay.lu/abss/test-book/",
|
||||
@@ -299,16 +291,10 @@ class TestExtractMagnetLink:
|
||||
# Should contain default trackers
|
||||
assert "udp%3A//tracker.openbittorrent.com%3A80" in magnet_link
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.requests.get')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.network.get_proxies')
|
||||
def test_extract_magnet_link_no_info_hash(self, mock_get_proxies, mock_get):
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
def test_extract_magnet_link_no_info_hash(self, mock_html_get):
|
||||
"""Test handling missing info hash."""
|
||||
mock_get_proxies.return_value = {}
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.text = "<html><body></body></html>"
|
||||
mock_get.return_value = mock_response
|
||||
mock_html_get.return_value = "<html><body></body></html>"
|
||||
|
||||
magnet_link = scraper.extract_magnet_link(
|
||||
"https://audiobookbay.lu/abss/test-book/",
|
||||
@@ -317,15 +303,10 @@ class TestExtractMagnetLink:
|
||||
|
||||
assert magnet_link is None
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.requests.get')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.network.get_proxies')
|
||||
def test_extract_magnet_link_non_200(self, mock_get_proxies, mock_get):
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
def test_extract_magnet_link_non_200(self, mock_html_get):
|
||||
"""Test handling non-200 status code."""
|
||||
mock_get_proxies.return_value = {}
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 404
|
||||
mock_get.return_value = mock_response
|
||||
mock_html_get.return_value = ""
|
||||
|
||||
magnet_link = scraper.extract_magnet_link(
|
||||
"https://audiobookbay.lu/abss/test-book/",
|
||||
@@ -334,13 +315,10 @@ class TestExtractMagnetLink:
|
||||
|
||||
assert magnet_link is None
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.requests.get')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.network.get_proxies')
|
||||
def test_extract_magnet_link_request_exception(self, mock_get_proxies, mock_get):
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
def test_extract_magnet_link_request_exception(self, mock_html_get):
|
||||
"""Test handling request exceptions."""
|
||||
mock_get_proxies.return_value = {}
|
||||
|
||||
mock_get.side_effect = requests.exceptions.RequestException("Connection error")
|
||||
mock_html_get.return_value = ""
|
||||
|
||||
magnet_link = scraper.extract_magnet_link(
|
||||
"https://audiobookbay.lu/abss/test-book/",
|
||||
@@ -349,12 +327,9 @@ class TestExtractMagnetLink:
|
||||
|
||||
assert magnet_link is None
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.requests.get')
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.network.get_proxies')
|
||||
def test_extract_magnet_link_cleans_info_hash(self, mock_get_proxies, mock_get):
|
||||
@patch('shelfmark.release_sources.audiobookbay.scraper.downloader.html_get_page')
|
||||
def test_extract_magnet_link_cleans_info_hash(self, mock_html_get):
|
||||
"""Test that info hash whitespace is cleaned."""
|
||||
mock_get_proxies.return_value = {}
|
||||
|
||||
html_with_whitespace = """
|
||||
<html>
|
||||
<body>
|
||||
@@ -368,10 +343,7 @@ class TestExtractMagnetLink:
|
||||
</html>
|
||||
"""
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.text = html_with_whitespace
|
||||
mock_get.return_value = mock_response
|
||||
mock_html_get.return_value = html_with_whitespace
|
||||
|
||||
magnet_link = scraper.extract_magnet_link(
|
||||
"https://audiobookbay.lu/abss/test-book/",
|
||||
|
||||
@@ -11,6 +11,8 @@ from shelfmark.release_sources.audiobookbay.source import (
|
||||
AudiobookBaySource,
|
||||
_map_language,
|
||||
_generate_source_id,
|
||||
_split_title_and_author,
|
||||
_parse_bitrate_to_kbps,
|
||||
)
|
||||
|
||||
|
||||
@@ -54,6 +56,21 @@ class TestMapLanguage:
|
||||
assert _map_language("\tFrench\n") == "fr"
|
||||
|
||||
|
||||
class TestParseBitrate:
|
||||
"""Tests for the _parse_bitrate_to_kbps function."""
|
||||
|
||||
def test_parse_bitrate_to_kbps(self):
|
||||
"""Test normal bitrate parsing from ABB string values."""
|
||||
assert _parse_bitrate_to_kbps("128 Kbps") == 128
|
||||
assert _parse_bitrate_to_kbps("192kbps") == 192
|
||||
|
||||
def test_parse_bitrate_to_kbps_invalid(self):
|
||||
"""Test invalid bitrate values return None."""
|
||||
assert _parse_bitrate_to_kbps(None) is None
|
||||
assert _parse_bitrate_to_kbps("") is None
|
||||
assert _parse_bitrate_to_kbps("Unknown") is None
|
||||
|
||||
|
||||
class TestGenerateSourceId:
|
||||
"""Tests for the _generate_source_id function."""
|
||||
|
||||
@@ -77,11 +94,27 @@ class TestGenerateSourceId:
|
||||
class TestAudiobookBaySource:
|
||||
"""Tests for the AudiobookBaySource class."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def configure_hostname(self, monkeypatch):
|
||||
"""Provide a default ABB hostname for source.search tests."""
|
||||
import shelfmark.release_sources.audiobookbay.source as source_module
|
||||
|
||||
original_get = source_module.config.get
|
||||
|
||||
def mock_get(key, default=None):
|
||||
if key == "ABB_HOSTNAME":
|
||||
return "audiobookbay.lu"
|
||||
return original_get(key, default)
|
||||
|
||||
monkeypatch.setattr(source_module.config, "get", mock_get)
|
||||
|
||||
def test_is_available_enabled(self, monkeypatch):
|
||||
"""Test is_available when enabled."""
|
||||
def mock_get(key, default=False):
|
||||
if key == "ABB_ENABLED":
|
||||
return True
|
||||
if key == "ABB_HOSTNAME":
|
||||
return "audiobookbay.lu"
|
||||
return default
|
||||
|
||||
import shelfmark.release_sources.audiobookbay.source as source_module
|
||||
@@ -302,7 +335,7 @@ class TestAudiobookBaySource:
|
||||
assert len(results) == 1
|
||||
release = results[0]
|
||||
assert release.source == "audiobookbay"
|
||||
assert release.title == "Test Book - Test Author"
|
||||
assert release.title == "Test Book"
|
||||
assert release.format == "m4b"
|
||||
assert release.language == "en"
|
||||
assert release.size == "500.00 MBs"
|
||||
@@ -314,9 +347,25 @@ class TestAudiobookBaySource:
|
||||
assert release.extra['preview'] == "https://example.com/cover.jpg"
|
||||
assert release.extra['detail_url'] == "https://audiobookbay.lu/abss/test-book/"
|
||||
assert release.extra['bitrate'] == "128 Kbps"
|
||||
assert release.extra['bitrate_value'] == 128
|
||||
assert release.extra['posted_date'] == "01 Jan 2024"
|
||||
assert release.extra['title_raw'] == "Test Book - Test Author"
|
||||
assert release.extra['author'] == "Test Author"
|
||||
assert release.extra['language_raw'] == "English"
|
||||
|
||||
def test_split_title_and_author(self):
|
||||
"""Test title/author parsing from ABB title patterns."""
|
||||
assert _split_title_and_author("Book Title - Author Name") == (
|
||||
"Book Title", "Author Name"
|
||||
)
|
||||
assert _split_title_and_author("Book Title - Author Name - Narrator") == (
|
||||
"Book Title - Author Name", "Narrator"
|
||||
)
|
||||
assert _split_title_and_author("Book Title") == ("Book Title", None)
|
||||
assert _split_title_and_author(" Book Title - Author Name ") == (
|
||||
"Book Title", "Author Name"
|
||||
)
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay')
|
||||
def test_search_handles_scraper_exception(self, mock_search):
|
||||
"""Test that scraper exceptions are handled gracefully."""
|
||||
@@ -413,16 +462,124 @@ class TestAudiobookBaySource:
|
||||
assert call_args.kwargs['hostname'] == "audiobookbay.is"
|
||||
assert call_args.kwargs['max_pages'] == 3
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay')
|
||||
def test_search_exact_phrase_setting_forwarded(self, mock_search, monkeypatch):
|
||||
"""Test that exact phrase setting is forwarded to scraper search."""
|
||||
mock_search.return_value = [
|
||||
{
|
||||
"title": "Test Book - Test Author",
|
||||
"link": "https://audiobookbay.lu/abss/test-book/",
|
||||
"cover": None,
|
||||
"language": "English",
|
||||
"format": "M4B",
|
||||
"bitrate": "128 Kbps",
|
||||
"size": "500 MB",
|
||||
"posted_date": "01 Jan 2024",
|
||||
}
|
||||
]
|
||||
|
||||
def mock_get(key, default=None):
|
||||
if key == "ABB_HOSTNAME":
|
||||
return "audiobookbay.lu"
|
||||
if key == "ABB_EXACT_PHRASE":
|
||||
return True
|
||||
return default
|
||||
|
||||
import shelfmark.release_sources.audiobookbay.source as source_module
|
||||
monkeypatch.setattr(source_module.config, "get", mock_get)
|
||||
|
||||
source = AudiobookBaySource()
|
||||
book = BookMetadata(
|
||||
provider="test",
|
||||
provider_id="123",
|
||||
title="Test Book",
|
||||
authors=["Test Author"],
|
||||
)
|
||||
plan = ReleaseSearchPlan(
|
||||
languages=["en"],
|
||||
isbn_candidates=[],
|
||||
author="Test Author",
|
||||
title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")],
|
||||
grouped_title_variants=[],
|
||||
)
|
||||
|
||||
source.search(book, plan, content_type="audiobook")
|
||||
|
||||
call_args = mock_search.call_args_list[0]
|
||||
assert call_args.kwargs["exact_phrase"] is True
|
||||
|
||||
@patch('shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay')
|
||||
def test_search_falls_back_to_broad_when_exact_finds_no_results(self, mock_search, monkeypatch):
|
||||
"""Test fallback to broad search when exact phrase search has no results."""
|
||||
mock_search.side_effect = [
|
||||
[],
|
||||
[
|
||||
{
|
||||
"title": "Test Book - Test Author",
|
||||
"link": "https://audiobookbay.lu/abss/test-book/",
|
||||
"cover": None,
|
||||
"language": "English",
|
||||
"format": "M4B",
|
||||
"bitrate": "128 Kbps",
|
||||
"size": "500 MB",
|
||||
"posted_date": "01 Jan 2024",
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
def mock_get(key, default=None):
|
||||
if key == "ABB_HOSTNAME":
|
||||
return "audiobookbay.lu"
|
||||
if key == "ABB_EXACT_PHRASE":
|
||||
return True
|
||||
return default
|
||||
|
||||
import shelfmark.release_sources.audiobookbay.source as source_module
|
||||
monkeypatch.setattr(source_module.config, "get", mock_get)
|
||||
|
||||
source = AudiobookBaySource()
|
||||
book = BookMetadata(
|
||||
provider="test",
|
||||
provider_id="123",
|
||||
title="Test Book",
|
||||
authors=["Test Author"],
|
||||
)
|
||||
plan = ReleaseSearchPlan(
|
||||
languages=["en"],
|
||||
isbn_candidates=[],
|
||||
author="Test Author",
|
||||
title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")],
|
||||
grouped_title_variants=[],
|
||||
)
|
||||
|
||||
results = source.search(book, plan, content_type="audiobook")
|
||||
|
||||
assert len(results) == 1
|
||||
assert mock_search.call_count == 2
|
||||
first_call = mock_search.call_args_list[0].kwargs
|
||||
second_call = mock_search.call_args_list[1].kwargs
|
||||
assert first_call["exact_phrase"] is True
|
||||
assert second_call["exact_phrase"] is False
|
||||
|
||||
def test_get_column_config(self):
|
||||
"""Test column configuration."""
|
||||
source = AudiobookBaySource()
|
||||
config = source.get_column_config()
|
||||
|
||||
assert config is not None
|
||||
assert len(config.columns) == 3
|
||||
assert len(config.columns) == 4
|
||||
column_keys = [col.key for col in config.columns]
|
||||
assert "language" in column_keys
|
||||
assert "format" in column_keys
|
||||
assert "extra.bitrate" in column_keys
|
||||
assert "size" in column_keys
|
||||
assert "seeders" not in column_keys # ABB doesn't show seeders
|
||||
assert config.supported_filters == ["format", "language"]
|
||||
|
||||
bitrate_col = next(col for col in config.columns if col.key == "extra.bitrate")
|
||||
assert bitrate_col.sortable is True
|
||||
assert bitrate_col.sort_key == "extra.bitrate_value"
|
||||
|
||||
size_col = next(col for col in config.columns if col.key == "size")
|
||||
assert size_col.sortable is True
|
||||
assert size_col.sort_key == "size_bytes"
|
||||
|
||||
@@ -4,7 +4,7 @@ Tests for AudiobookBay utility functions.
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.release_sources.audiobookbay.utils import parse_size, sanitize_title
|
||||
from shelfmark.release_sources.audiobookbay.utils import parse_size
|
||||
|
||||
|
||||
class TestParseSize:
|
||||
@@ -66,38 +66,3 @@ class TestParseSize:
|
||||
"""Test parsing with various whitespace."""
|
||||
assert parse_size(" 1 GB ") == 1024 ** 3
|
||||
assert parse_size("1.5\tMB") == int(1.5 * (1024 ** 2))
|
||||
|
||||
|
||||
class TestSanitizeTitle:
|
||||
"""Tests for the sanitize_title function."""
|
||||
|
||||
def test_sanitize_title_removes_invalid_chars(self):
|
||||
"""Test removing invalid filename characters."""
|
||||
assert sanitize_title("Test<Book>") == "TestBook"
|
||||
assert sanitize_title("Test:Book") == "TestBook"
|
||||
assert sanitize_title("Test/Book") == "TestBook"
|
||||
assert sanitize_title("Test\\Book") == "TestBook"
|
||||
assert sanitize_title("Test|Book") == "TestBook"
|
||||
assert sanitize_title("Test?Book") == "TestBook"
|
||||
assert sanitize_title("Test*Book") == "TestBook"
|
||||
assert sanitize_title('Test"Book') == "TestBook"
|
||||
|
||||
def test_sanitize_title_preserves_valid_chars(self):
|
||||
"""Test that valid characters are preserved."""
|
||||
assert sanitize_title("Test Book - Author") == "Test Book - Author"
|
||||
assert sanitize_title("Test Book (2024)") == "Test Book (2024)"
|
||||
assert sanitize_title("Test Book [Special]") == "Test Book [Special]"
|
||||
assert sanitize_title("Test Book's Title") == "Test Book's Title"
|
||||
|
||||
def test_sanitize_title_strips_whitespace(self):
|
||||
"""Test that leading/trailing whitespace is stripped."""
|
||||
assert sanitize_title(" Test Book ") == "Test Book"
|
||||
assert sanitize_title("\tTest Book\n") == "Test Book"
|
||||
|
||||
def test_sanitize_title_empty(self):
|
||||
"""Test that empty string returns empty."""
|
||||
assert sanitize_title("") == ""
|
||||
|
||||
def test_sanitize_title_multiple_invalid_chars(self):
|
||||
"""Test removing multiple invalid characters."""
|
||||
assert sanitize_title("Test<Book>:Author/Title") == "TestBookAuthorTitle"
|
||||
|
||||
@@ -4,7 +4,7 @@ Tests for bencode encoding/decoding in the torrent utilities.
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.torrent_utils import (
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
bencode_encode as _bencode_encode,
|
||||
bencode_decode as _bencode_decode,
|
||||
extract_info_hash_from_torrent as _extract_info_hash_from_torrent,
|
||||
|
||||
@@ -4,7 +4,7 @@ Tests for the download client infrastructure.
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients import (
|
||||
from shelfmark.download.clients import (
|
||||
DownloadStatus,
|
||||
DownloadState,
|
||||
DownloadClient,
|
||||
|
||||
@@ -18,7 +18,7 @@ import pytest
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler
|
||||
from shelfmark.release_sources.prowlarr.clients import (
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadState,
|
||||
DownloadStatus,
|
||||
|
||||
@@ -16,7 +16,7 @@ import pytest
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler
|
||||
from shelfmark.release_sources.prowlarr.utils import get_protocol
|
||||
from shelfmark.release_sources.prowlarr.clients import (
|
||||
from shelfmark.download.clients import (
|
||||
DownloadStatus,
|
||||
DownloadState,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ import pytest
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.settings_registry import save_config_file
|
||||
from shelfmark.release_sources.prowlarr.clients import DownloadStatus
|
||||
from shelfmark.download.clients import DownloadStatus
|
||||
|
||||
|
||||
# Test magnet link (Ubuntu ISO - legal, small metadata)
|
||||
@@ -115,7 +115,7 @@ def _try_get_transmission_client():
|
||||
"""Try to get a working Transmission client, or None if unavailable."""
|
||||
_setup_transmission_config()
|
||||
try:
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import TransmissionClient
|
||||
from shelfmark.download.clients.transmission import TransmissionClient
|
||||
client = TransmissionClient()
|
||||
client.test_connection()
|
||||
return client
|
||||
@@ -127,7 +127,7 @@ def _try_get_qbittorrent_client():
|
||||
"""Try to get a working qBittorrent client, or None if unavailable."""
|
||||
_setup_qbittorrent_config()
|
||||
try:
|
||||
from shelfmark.release_sources.prowlarr.clients.qbittorrent import QBittorrentClient
|
||||
from shelfmark.download.clients.qbittorrent import QBittorrentClient
|
||||
client = QBittorrentClient()
|
||||
success, _ = client.test_connection()
|
||||
if success:
|
||||
@@ -141,7 +141,7 @@ def _try_get_deluge_client():
|
||||
"""Try to get a working Deluge client, or None if unavailable."""
|
||||
_setup_deluge_config()
|
||||
try:
|
||||
from shelfmark.release_sources.prowlarr.clients.deluge import DelugeClient
|
||||
from shelfmark.download.clients.deluge import DelugeClient
|
||||
client = DelugeClient()
|
||||
success, _ = client.test_connection()
|
||||
if success:
|
||||
@@ -155,10 +155,12 @@ def _try_get_nzbget_client():
|
||||
"""Try to get a working NZBGet client, or None if unavailable."""
|
||||
_setup_nzbget_config()
|
||||
try:
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import NZBGetClient
|
||||
from shelfmark.download.clients.nzbget import NZBGetClient
|
||||
client = NZBGetClient()
|
||||
client.test_connection()
|
||||
return client
|
||||
success, _ = client.test_connection()
|
||||
if success:
|
||||
return client
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -168,7 +170,7 @@ def _try_get_sabnzbd_client():
|
||||
if not _setup_sabnzbd_config():
|
||||
return None
|
||||
try:
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import SABnzbdClient
|
||||
from shelfmark.download.clients.sabnzbd import SABnzbdClient
|
||||
client = SABnzbdClient()
|
||||
success, _ = client.test_connection()
|
||||
if success:
|
||||
|
||||
@@ -18,7 +18,7 @@ import pytest
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.settings_registry import save_config_file
|
||||
from shelfmark.release_sources.prowlarr.clients import DownloadStatus, DownloadState
|
||||
from shelfmark.download.clients import DownloadStatus, DownloadState
|
||||
|
||||
|
||||
# Invalid magnet - valid format but non-existent torrent
|
||||
@@ -74,7 +74,7 @@ def _setup_deluge_config():
|
||||
def _try_get_transmission_client():
|
||||
_setup_transmission_config()
|
||||
try:
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import TransmissionClient
|
||||
from shelfmark.download.clients.transmission import TransmissionClient
|
||||
client = TransmissionClient()
|
||||
client.test_connection()
|
||||
return client
|
||||
@@ -85,7 +85,7 @@ def _try_get_transmission_client():
|
||||
def _try_get_qbittorrent_client():
|
||||
_setup_qbittorrent_config()
|
||||
try:
|
||||
from shelfmark.release_sources.prowlarr.clients.qbittorrent import QBittorrentClient
|
||||
from shelfmark.download.clients.qbittorrent import QBittorrentClient
|
||||
client = QBittorrentClient()
|
||||
success, _ = client.test_connection()
|
||||
if success:
|
||||
@@ -98,7 +98,7 @@ def _try_get_qbittorrent_client():
|
||||
def _try_get_deluge_client():
|
||||
_setup_deluge_config()
|
||||
try:
|
||||
from shelfmark.release_sources.prowlarr.clients.deluge import DelugeClient
|
||||
from shelfmark.download.clients.deluge import DelugeClient
|
||||
client = DelugeClient()
|
||||
success, _ = client.test_connection()
|
||||
if success:
|
||||
|
||||
@@ -39,7 +39,7 @@ def _is_transmission_available():
|
||||
"""Check if Transmission is available."""
|
||||
_setup_transmission_config()
|
||||
try:
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import TransmissionClient
|
||||
from shelfmark.download.clients.transmission import TransmissionClient
|
||||
client = TransmissionClient()
|
||||
success, _ = client.test_connection()
|
||||
return success
|
||||
|
||||
@@ -8,7 +8,7 @@ without requiring a running NZBGet instance.
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients import DownloadStatus
|
||||
from shelfmark.download.clients import DownloadStatus
|
||||
|
||||
|
||||
class TestNZBGetClientIsConfigured:
|
||||
@@ -21,11 +21,11 @@ class TestNZBGetClientIsConfigured:
|
||||
"NZBGET_URL": "http://localhost:6789",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -38,11 +38,11 @@ class TestNZBGetClientIsConfigured:
|
||||
"NZBGET_URL": "http://localhost:6789",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -55,11 +55,11 @@ class TestNZBGetClientIsConfigured:
|
||||
"NZBGET_URL": "",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -78,7 +78,7 @@ class TestNZBGetClientTestConnection:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -86,10 +86,10 @@ class TestNZBGetClientTestConnection:
|
||||
mock_response.json.return_value = {"result": {"Version": "21.1"}}
|
||||
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.requests.post",
|
||||
"shelfmark.download.clients.nzbget.requests.post",
|
||||
return_value=mock_response,
|
||||
):
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -110,15 +110,15 @@ class TestNZBGetClientTestConnection:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.requests.post",
|
||||
"shelfmark.download.clients.nzbget.requests.post",
|
||||
side_effect=requests.exceptions.ConnectionError("Connection refused"),
|
||||
):
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -139,15 +139,15 @@ class TestNZBGetClientTestConnection:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.requests.post",
|
||||
"shelfmark.download.clients.nzbget.requests.post",
|
||||
side_effect=requests.exceptions.Timeout("Timeout"),
|
||||
):
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -170,7 +170,7 @@ class TestNZBGetClientRPCCall:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -178,10 +178,10 @@ class TestNZBGetClientRPCCall:
|
||||
mock_response.json.return_value = {"result": "test_result"}
|
||||
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.requests.post",
|
||||
"shelfmark.download.clients.nzbget.requests.post",
|
||||
return_value=mock_response,
|
||||
) as mock_post:
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -202,7 +202,7 @@ class TestNZBGetClientRPCCall:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -210,10 +210,10 @@ class TestNZBGetClientRPCCall:
|
||||
mock_response.json.return_value = {"error": {"message": "Invalid method"}}
|
||||
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.requests.post",
|
||||
"shelfmark.download.clients.nzbget.requests.post",
|
||||
return_value=mock_response,
|
||||
):
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -236,7 +236,7 @@ class TestNZBGetClientGetStatus:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -256,7 +256,7 @@ class TestNZBGetClientGetStatus:
|
||||
]
|
||||
return []
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -285,7 +285,7 @@ class TestNZBGetClientGetStatus:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -302,7 +302,7 @@ class TestNZBGetClientGetStatus:
|
||||
]
|
||||
return []
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -330,7 +330,7 @@ class TestNZBGetClientGetStatus:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -347,7 +347,7 @@ class TestNZBGetClientGetStatus:
|
||||
]
|
||||
return []
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -373,14 +373,14 @@ class TestNZBGetClientGetStatus:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
def mock_rpc_call(method, params=None):
|
||||
return [] # Empty queue and history
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -406,7 +406,7 @@ class TestNZBGetClientGetStatus:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -426,7 +426,7 @@ class TestNZBGetClientGetStatus:
|
||||
]
|
||||
return []
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -455,7 +455,7 @@ class TestNZBGetClientAddDownload:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -468,13 +468,13 @@ class TestNZBGetClientAddDownload:
|
||||
mock_post_response.json.return_value = {"result": 456}
|
||||
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.requests.get",
|
||||
"shelfmark.download.clients.nzbget.requests.get",
|
||||
return_value=mock_get_response,
|
||||
), patch(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.requests.post",
|
||||
"shelfmark.download.clients.nzbget.requests.post",
|
||||
return_value=mock_post_response,
|
||||
):
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -497,15 +497,15 @@ class TestNZBGetClientAddDownload:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.requests.get",
|
||||
"shelfmark.download.clients.nzbget.requests.get",
|
||||
side_effect=requests.RequestException("Failed to fetch"),
|
||||
):
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -528,7 +528,7 @@ class TestNZBGetClientRemove:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -537,7 +537,7 @@ class TestNZBGetClientRemove:
|
||||
return True
|
||||
return None
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -562,7 +562,7 @@ class TestNZBGetClientRemove:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -574,7 +574,7 @@ class TestNZBGetClientRemove:
|
||||
return True
|
||||
return None
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import (
|
||||
from shelfmark.download.clients.nzbget import (
|
||||
NZBGetClient,
|
||||
)
|
||||
|
||||
@@ -603,7 +603,7 @@ class TestNZBGetClientRemove:
|
||||
"NZBGET_CATEGORY": "Books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
|
||||
"shelfmark.download.clients.nzbget.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -616,7 +616,7 @@ class TestNZBGetClientRemove:
|
||||
return params is not None and params[0] == "HistoryDelete"
|
||||
return None
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.nzbget import NZBGetClient
|
||||
from shelfmark.download.clients.nzbget import NZBGetClient
|
||||
|
||||
with patch.object(NZBGetClient, "__init__", lambda x: None):
|
||||
client = NZBGetClient()
|
||||
|
||||
@@ -9,8 +9,8 @@ import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients import DownloadStatus
|
||||
from shelfmark.release_sources.prowlarr.clients.torrent_utils import TorrentInfo
|
||||
from shelfmark.download.clients import DownloadStatus
|
||||
from shelfmark.download.clients.torrent_utils import TorrentInfo
|
||||
|
||||
|
||||
class MockTorrent:
|
||||
@@ -66,11 +66,11 @@ class TestQBittorrentClientIsConfigured:
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.qbittorrent import (
|
||||
from shelfmark.download.clients.qbittorrent import (
|
||||
QBittorrentClient,
|
||||
)
|
||||
|
||||
@@ -83,11 +83,11 @@ class TestQBittorrentClientIsConfigured:
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.qbittorrent import (
|
||||
from shelfmark.download.clients.qbittorrent import (
|
||||
QBittorrentClient,
|
||||
)
|
||||
|
||||
@@ -100,11 +100,11 @@ class TestQBittorrentClientIsConfigured:
|
||||
"QBITTORRENT_URL": "",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.qbittorrent import (
|
||||
from shelfmark.download.clients.qbittorrent import (
|
||||
QBittorrentClient,
|
||||
)
|
||||
|
||||
@@ -123,7 +123,7 @@ class TestQBittorrentClientTestConnection:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -135,7 +135,7 @@ class TestQBittorrentClientTestConnection:
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
# Need to reimport after patching
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -153,7 +153,7 @@ class TestQBittorrentClientTestConnection:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -163,7 +163,7 @@ class TestQBittorrentClientTestConnection:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -185,7 +185,7 @@ class TestQBittorrentClientGetStatus:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -197,7 +197,7 @@ class TestQBittorrentClientGetStatus:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -218,7 +218,7 @@ class TestQBittorrentClientGetStatus:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -234,7 +234,7 @@ class TestQBittorrentClientGetStatus:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -253,7 +253,7 @@ class TestQBittorrentClientGetStatus:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -272,7 +272,7 @@ class TestQBittorrentClientGetStatus:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -290,7 +290,7 @@ class TestQBittorrentClientGetStatus:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -335,7 +335,7 @@ class TestQBittorrentClientGetStatus:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -352,7 +352,7 @@ class TestQBittorrentClientGetStatus:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -367,7 +367,7 @@ class TestQBittorrentClientGetStatus:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -386,7 +386,7 @@ class TestQBittorrentClientGetStatus:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -398,7 +398,7 @@ class TestQBittorrentClientGetStatus:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -417,7 +417,7 @@ class TestQBittorrentClientGetStatus:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -429,7 +429,7 @@ class TestQBittorrentClientGetStatus:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -446,7 +446,7 @@ class TestQBittorrentClientGetStatus:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -458,7 +458,7 @@ class TestQBittorrentClientGetStatus:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -479,7 +479,7 @@ class TestQBittorrentClientAddDownload:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -493,7 +493,7 @@ class TestQBittorrentClientAddDownload:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -512,7 +512,7 @@ class TestQBittorrentClientAddDownload:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -526,11 +526,11 @@ class TestQBittorrentClientAddDownload:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.extract_torrent_info",
|
||||
"shelfmark.download.clients.qbittorrent.extract_torrent_info",
|
||||
autospec=True,
|
||||
) as mock_extract:
|
||||
mock_extract.return_value = TorrentInfo(
|
||||
@@ -562,7 +562,7 @@ class TestQBittorrentClientAddDownload:
|
||||
"QBITTORRENT_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -578,7 +578,7 @@ class TestQBittorrentClientAddDownload:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -600,7 +600,7 @@ class TestQBittorrentClientRemove:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -609,7 +609,7 @@ class TestQBittorrentClientRemove:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -629,7 +629,7 @@ class TestQBittorrentClientRemove:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -639,7 +639,7 @@ class TestQBittorrentClientRemove:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -659,7 +659,7 @@ class TestQBittorrentClientGetDownloadPath:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -673,7 +673,7 @@ class TestQBittorrentClientGetDownloadPath:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -690,7 +690,7 @@ class TestQBittorrentClientGetDownloadPath:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -708,7 +708,7 @@ class TestQBittorrentClientGetDownloadPath:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -725,7 +725,7 @@ class TestQBittorrentClientGetDownloadPath:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -766,7 +766,7 @@ class TestQBittorrentClientGetDownloadPath:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -782,7 +782,7 @@ class TestQBittorrentClientGetDownloadPath:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -825,7 +825,7 @@ class TestQBittorrentClientGetDownloadPath:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -846,7 +846,7 @@ class TestQBittorrentClientFindExisting:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -862,7 +862,7 @@ class TestQBittorrentClientFindExisting:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -883,7 +883,7 @@ class TestQBittorrentClientFindExisting:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -898,7 +898,7 @@ class TestQBittorrentClientFindExisting:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -916,7 +916,7 @@ class TestQBittorrentClientFindExisting:
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -925,7 +925,7 @@ class TestQBittorrentClientFindExisting:
|
||||
|
||||
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
@@ -938,34 +938,34 @@ class TestHashesMatch:
|
||||
"""Tests for _hashes_match() - Amarr compatibility."""
|
||||
|
||||
def test_identical_hashes_match(self):
|
||||
from shelfmark.release_sources.prowlarr.clients.qbittorrent import _hashes_match
|
||||
from shelfmark.download.clients.qbittorrent import _hashes_match
|
||||
assert _hashes_match("abc123", "abc123") is True
|
||||
assert _hashes_match("ABC123", "abc123") is True
|
||||
|
||||
def test_different_hashes_dont_match(self):
|
||||
from shelfmark.release_sources.prowlarr.clients.qbittorrent import _hashes_match
|
||||
from shelfmark.download.clients.qbittorrent import _hashes_match
|
||||
assert _hashes_match("abc123", "def456") is False
|
||||
|
||||
def test_amarr_padded_hash_matches_ed2k_hash(self):
|
||||
from shelfmark.release_sources.prowlarr.clients.qbittorrent import _hashes_match
|
||||
from shelfmark.download.clients.qbittorrent import _hashes_match
|
||||
ed2k_hash = "0320c47b3baa01f8d5f42cd7c05ce28d" # 32 chars
|
||||
padded_hash = "0320c47b3baa01f8d5f42cd7c05ce28d00000000" # 40 chars
|
||||
assert _hashes_match(padded_hash, ed2k_hash) is True
|
||||
assert _hashes_match(ed2k_hash, padded_hash) is True
|
||||
|
||||
def test_non_zero_padded_40_char_hash_doesnt_match(self):
|
||||
from shelfmark.release_sources.prowlarr.clients.qbittorrent import _hashes_match
|
||||
from shelfmark.download.clients.qbittorrent import _hashes_match
|
||||
bittorrent_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
||||
partial_hash = "3b245504cf5f11bbdbe1201cea6a6bf4"
|
||||
assert _hashes_match(bittorrent_hash, partial_hash) is False
|
||||
|
||||
def test_matching_is_case_insensitive(self):
|
||||
from shelfmark.release_sources.prowlarr.clients.qbittorrent import _hashes_match
|
||||
from shelfmark.download.clients.qbittorrent import _hashes_match
|
||||
ed2k_hash = "0320C47B3BAA01F8D5F42CD7C05CE28D"
|
||||
padded_hash = "0320c47b3baa01f8d5f42cd7c05ce28d00000000"
|
||||
assert _hashes_match(padded_hash, ed2k_hash) is True
|
||||
|
||||
def test_wrong_length_hashes_dont_match(self):
|
||||
from shelfmark.release_sources.prowlarr.clients.qbittorrent import _hashes_match
|
||||
from shelfmark.download.clients.qbittorrent import _hashes_match
|
||||
assert _hashes_match("a" * 40, "b" * 30) is False
|
||||
assert _hashes_match("a" * 38, "b" * 32) is False
|
||||
|
||||
@@ -9,7 +9,7 @@ from threading import Event
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.release_sources.prowlarr.clients import DownloadState, DownloadStatus
|
||||
from shelfmark.download.clients import DownloadState, DownloadStatus
|
||||
from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients import DownloadStatus
|
||||
from shelfmark.download.clients import DownloadStatus
|
||||
|
||||
|
||||
def make_config_getter(values):
|
||||
@@ -38,11 +38,11 @@ class TestRTorrentClientIsConfigured:
|
||||
"RTORRENT_URL": "http://localhost:8080/RPC2",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import RTorrentClient
|
||||
from shelfmark.download.clients.rtorrent import RTorrentClient
|
||||
|
||||
assert RTorrentClient.is_configured() is True
|
||||
|
||||
@@ -53,11 +53,11 @@ class TestRTorrentClientIsConfigured:
|
||||
"RTORRENT_URL": "http://localhost:8080/RPC2",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import RTorrentClient
|
||||
from shelfmark.download.clients.rtorrent import RTorrentClient
|
||||
|
||||
assert RTorrentClient.is_configured() is False
|
||||
|
||||
@@ -68,11 +68,11 @@ class TestRTorrentClientIsConfigured:
|
||||
"RTORRENT_URL": "",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import RTorrentClient
|
||||
from shelfmark.download.clients.rtorrent import RTorrentClient
|
||||
|
||||
assert RTorrentClient.is_configured() is False
|
||||
|
||||
@@ -90,7 +90,7 @@ class TestRTorrentClientTestConnection:
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -101,10 +101,10 @@ class TestRTorrentClientTestConnection:
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.rtorrent"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
@@ -124,7 +124,7 @@ class TestRTorrentClientTestConnection:
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -135,10 +135,10 @@ class TestRTorrentClientTestConnection:
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.rtorrent"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
@@ -158,7 +158,7 @@ class TestRTorrentClientTestConnection:
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -169,10 +169,10 @@ class TestRTorrentClientTestConnection:
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.rtorrent"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
@@ -196,7 +196,7 @@ class TestRTorrentClientAddDownload:
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -213,15 +213,15 @@ class TestRTorrentClientAddDownload:
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.clients.torrent_utils.extract_torrent_info",
|
||||
"shelfmark.download.clients.torrent_utils.extract_torrent_info",
|
||||
return_value=mock_torrent_info,
|
||||
):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules[
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent"
|
||||
"shelfmark.download.clients.rtorrent"
|
||||
]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
@@ -247,7 +247,7 @@ class TestRTorrentClientAddDownload:
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -264,15 +264,15 @@ class TestRTorrentClientAddDownload:
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.clients.torrent_utils.extract_torrent_info",
|
||||
"shelfmark.download.clients.torrent_utils.extract_torrent_info",
|
||||
return_value=mock_torrent_info,
|
||||
):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules[
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent"
|
||||
"shelfmark.download.clients.rtorrent"
|
||||
]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
@@ -293,7 +293,7 @@ class TestRTorrentClientAddDownload:
|
||||
"RTORRENT_URL": "http://localhost:8080/RPC2",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -309,15 +309,15 @@ class TestRTorrentClientAddDownload:
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.extract_torrent_info",
|
||||
"shelfmark.download.clients.rtorrent.extract_torrent_info",
|
||||
return_value=mock_torrent_info,
|
||||
):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules[
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent"
|
||||
"shelfmark.download.clients.rtorrent"
|
||||
]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
@@ -341,7 +341,7 @@ class TestRTorrentClientGetStatus:
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -363,10 +363,10 @@ class TestRTorrentClientGetStatus:
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.rtorrent"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
@@ -388,7 +388,7 @@ class TestRTorrentClientGetStatus:
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -413,10 +413,10 @@ class TestRTorrentClientGetStatus:
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.rtorrent"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
@@ -439,7 +439,7 @@ class TestRTorrentClientGetStatus:
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -450,10 +450,10 @@ class TestRTorrentClientGetStatus:
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.rtorrent"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
@@ -473,7 +473,7 @@ class TestRTorrentClientGetStatus:
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -495,10 +495,10 @@ class TestRTorrentClientGetStatus:
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.rtorrent"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
@@ -522,7 +522,7 @@ class TestRTorrentClientRemove:
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -532,10 +532,10 @@ class TestRTorrentClientRemove:
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.rtorrent"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
@@ -556,7 +556,7 @@ class TestRTorrentClientRemove:
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -566,10 +566,10 @@ class TestRTorrentClientRemove:
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.rtorrent"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
@@ -590,7 +590,7 @@ class TestRTorrentClientRemove:
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -601,10 +601,10 @@ class TestRTorrentClientRemove:
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.rtorrent"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
@@ -627,7 +627,7 @@ class TestRTorrentClientGetDownloadPath:
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -638,10 +638,10 @@ class TestRTorrentClientGetDownloadPath:
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.rtorrent"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
@@ -660,7 +660,7 @@ class TestRTorrentClientGetDownloadPath:
|
||||
"RTORRENT_LABEL": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.rtorrent.config.get",
|
||||
"shelfmark.download.clients.rtorrent.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -671,10 +671,10 @@ class TestRTorrentClientGetDownloadPath:
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
|
||||
with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.rtorrent"]
|
||||
if "shelfmark.download.clients.rtorrent" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.rtorrent"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.rtorrent import (
|
||||
from shelfmark.download.clients.rtorrent import (
|
||||
RTorrentClient,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ without requiring a running SABnzbd instance.
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients import DownloadStatus
|
||||
from shelfmark.download.clients import DownloadStatus
|
||||
|
||||
|
||||
class TestSABnzbdClientIsConfigured:
|
||||
@@ -22,11 +22,11 @@ class TestSABnzbdClientIsConfigured:
|
||||
"SABNZBD_API_KEY": "abc123",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -40,11 +40,11 @@ class TestSABnzbdClientIsConfigured:
|
||||
"SABNZBD_API_KEY": "abc123",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -58,11 +58,11 @@ class TestSABnzbdClientIsConfigured:
|
||||
"SABNZBD_API_KEY": "abc123",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -76,11 +76,11 @@ class TestSABnzbdClientIsConfigured:
|
||||
"SABNZBD_API_KEY": "",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -98,7 +98,7 @@ class TestSABnzbdClientTestConnection:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -106,10 +106,10 @@ class TestSABnzbdClientTestConnection:
|
||||
mock_response.json.return_value = {"version": "4.2.1"}
|
||||
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.requests.get",
|
||||
"shelfmark.download.clients.sabnzbd.requests.get",
|
||||
return_value=mock_response,
|
||||
):
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -129,15 +129,15 @@ class TestSABnzbdClientTestConnection:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.requests.get",
|
||||
"shelfmark.download.clients.sabnzbd.requests.get",
|
||||
side_effect=requests.exceptions.ConnectionError("Connection refused"),
|
||||
):
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -159,7 +159,7 @@ class TestSABnzbdClientGetStatus:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -181,7 +181,7 @@ class TestSABnzbdClientGetStatus:
|
||||
}
|
||||
return {}
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -208,7 +208,7 @@ class TestSABnzbdClientGetStatus:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -230,7 +230,7 @@ class TestSABnzbdClientGetStatus:
|
||||
}
|
||||
return {}
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -260,7 +260,7 @@ class TestSABnzbdClientGetStatus:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -281,7 +281,7 @@ class TestSABnzbdClientGetStatus:
|
||||
}
|
||||
return {}
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -307,7 +307,7 @@ class TestSABnzbdClientGetStatus:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -328,7 +328,7 @@ class TestSABnzbdClientGetStatus:
|
||||
}
|
||||
return {}
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -352,7 +352,7 @@ class TestSABnzbdClientGetStatus:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -363,7 +363,7 @@ class TestSABnzbdClientGetStatus:
|
||||
return {"history": {"slots": []}}
|
||||
return {}
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -387,7 +387,7 @@ class TestSABnzbdClientGetStatus:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -408,7 +408,7 @@ class TestSABnzbdClientGetStatus:
|
||||
}
|
||||
return {}
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -431,7 +431,7 @@ class TestSABnzbdClientGetStatus:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -452,7 +452,7 @@ class TestSABnzbdClientGetStatus:
|
||||
}
|
||||
return {}
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -479,11 +479,11 @@ class TestSABnzbdClientAddDownload:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -509,11 +509,11 @@ class TestSABnzbdClientAddDownload:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -544,11 +544,11 @@ class TestSABnzbdClientAddDownload:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -580,7 +580,7 @@ class TestSABnzbdClientRemove:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -589,7 +589,7 @@ class TestSABnzbdClientRemove:
|
||||
return {"status": True}
|
||||
return {}
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -612,7 +612,7 @@ class TestSABnzbdClientRemove:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -627,7 +627,7 @@ class TestSABnzbdClientRemove:
|
||||
return {"status": True} # Found in history
|
||||
return {}
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -655,7 +655,7 @@ class TestSABnzbdClientFindExisting:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -678,7 +678,7 @@ class TestSABnzbdClientFindExisting:
|
||||
}
|
||||
return {"history": {"slots": []}}
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -703,7 +703,7 @@ class TestSABnzbdClientFindExisting:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -726,7 +726,7 @@ class TestSABnzbdClientFindExisting:
|
||||
}
|
||||
return {}
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -751,7 +751,7 @@ class TestSABnzbdClientFindExisting:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -762,7 +762,7 @@ class TestSABnzbdClientFindExisting:
|
||||
return {"history": {"slots": []}}
|
||||
return {}
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
@@ -789,7 +789,7 @@ class TestSABnzbdClientFindExisting:
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
@@ -826,7 +826,7 @@ class TestSABnzbdClientFindExisting:
|
||||
}
|
||||
return {}
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
from shelfmark.download.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.torrent_utils import (
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
parse_transmission_url,
|
||||
bencode_decode,
|
||||
bencode_encode,
|
||||
|
||||
@@ -10,7 +10,7 @@ from datetime import timedelta
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients import DownloadStatus
|
||||
from shelfmark.download.clients import DownloadStatus
|
||||
|
||||
|
||||
class MockTorrentStatus:
|
||||
@@ -76,11 +76,11 @@ class TestTransmissionClientIsConfigured:
|
||||
"TRANSMISSION_URL": "http://localhost:9091",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -93,11 +93,11 @@ class TestTransmissionClientIsConfigured:
|
||||
"TRANSMISSION_URL": "http://localhost:9091",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -110,11 +110,11 @@ class TestTransmissionClientIsConfigured:
|
||||
"TRANSMISSION_URL": "",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -133,7 +133,7 @@ class TestTransmissionClientTestConnection:
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -144,10 +144,10 @@ class TestTransmissionClientTestConnection:
|
||||
mock_transmission_rpc.Client.return_value = mock_client_instance
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.transmission"]
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -163,7 +163,7 @@ class TestTransmissionClientTestConnection:
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -175,10 +175,10 @@ class TestTransmissionClientTestConnection:
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
# Force reimport to use mock
|
||||
if "shelfmark.release_sources.prowlarr.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.transmission"]
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -197,7 +197,7 @@ class TestTransmissionClientTestConnection:
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -208,10 +208,10 @@ class TestTransmissionClientTestConnection:
|
||||
mock_transmission_rpc.Client.return_value = mock_client_instance
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.transmission"]
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -234,7 +234,7 @@ class TestTransmissionClientGetStatus:
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -246,10 +246,10 @@ class TestTransmissionClientGetStatus:
|
||||
mock_transmission_rpc.Client.return_value = mock_client_instance
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.transmission"]
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -270,7 +270,7 @@ class TestTransmissionClientGetStatus:
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -286,10 +286,10 @@ class TestTransmissionClientGetStatus:
|
||||
mock_transmission_rpc.Client.return_value = mock_client_instance
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.transmission"]
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -309,7 +309,7 @@ class TestTransmissionClientGetStatus:
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -320,10 +320,10 @@ class TestTransmissionClientGetStatus:
|
||||
mock_transmission_rpc.Client.return_value = mock_client_instance
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.transmission"]
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -342,7 +342,7 @@ class TestTransmissionClientGetStatus:
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -354,10 +354,10 @@ class TestTransmissionClientGetStatus:
|
||||
mock_transmission_rpc.Client.return_value = mock_client_instance
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.transmission"]
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -375,7 +375,7 @@ class TestTransmissionClientGetStatus:
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -387,10 +387,10 @@ class TestTransmissionClientGetStatus:
|
||||
mock_transmission_rpc.Client.return_value = mock_client_instance
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.transmission"]
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -412,7 +412,7 @@ class TestTransmissionClientAddDownload:
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -424,10 +424,10 @@ class TestTransmissionClientAddDownload:
|
||||
mock_transmission_rpc.Client.return_value = mock_client_instance
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.transmission"]
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -447,7 +447,7 @@ class TestTransmissionClientAddDownload:
|
||||
"TRANSMISSION_CATEGORY": "mybooks",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -459,10 +459,10 @@ class TestTransmissionClientAddDownload:
|
||||
mock_transmission_rpc.Client.return_value = mock_client_instance
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.transmission"]
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -487,7 +487,7 @@ class TestTransmissionClientRemove:
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -497,10 +497,10 @@ class TestTransmissionClientRemove:
|
||||
mock_transmission_rpc.Client.return_value = mock_client_instance
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.transmission"]
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -521,7 +521,7 @@ class TestTransmissionClientRemove:
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -532,10 +532,10 @@ class TestTransmissionClientRemove:
|
||||
mock_transmission_rpc.Client.return_value = mock_client_instance
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.transmission"]
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -557,7 +557,7 @@ class TestTransmissionClientFindExisting:
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -573,10 +573,10 @@ class TestTransmissionClientFindExisting:
|
||||
mock_transmission_rpc.Client.return_value = mock_client_instance
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.transmission"]
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
@@ -598,7 +598,7 @@ class TestTransmissionClientFindExisting:
|
||||
"TRANSMISSION_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.transmission.config.get",
|
||||
"shelfmark.download.clients.transmission.config.get",
|
||||
make_config_getter(config_values),
|
||||
)
|
||||
|
||||
@@ -609,10 +609,10 @@ class TestTransmissionClientFindExisting:
|
||||
mock_transmission_rpc.Client.return_value = mock_client_instance
|
||||
|
||||
with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}):
|
||||
if "shelfmark.release_sources.prowlarr.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.release_sources.prowlarr.clients.transmission"]
|
||||
if "shelfmark.download.clients.transmission" in sys.modules:
|
||||
del sys.modules["shelfmark.download.clients.transmission"]
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.transmission import (
|
||||
from shelfmark.download.clients.transmission import (
|
||||
TransmissionClient,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user