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:
Alex
2026-02-16 14:52:46 +00:00
committed by GitHub
parent dd6fd1e199
commit 7fdf55f5fd
42 changed files with 2640 additions and 2170 deletions
@@ -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
+753
View File
@@ -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
@@ -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,
)
@@ -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,
@@ -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,
)
@@ -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,
)
@@ -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 {}
+657
View File
@@ -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},
),
]
@@ -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
@@ -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
View File
@@ -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(
+43 -142
View File
@@ -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
+106 -46
View File
@@ -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
)
+29 -21
View File
@@ -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}")
+59 -688
View File
@@ -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)
+7 -666
View File
@@ -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 }> = [
+175 -9
View File
@@ -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 = ({
+3
View File
@@ -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 }),
+70 -53
View File
@@ -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
+98 -126
View File
@@ -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/",
+159 -2
View File
@@ -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"
+1 -36
View File
@@ -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"
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
)
+10 -8
View File
@@ -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:
+4 -4
View File
@@ -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:
+1 -1
View File
@@ -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
+45 -45
View File
@@ -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()
+59 -59
View File
@@ -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
+1 -1
View File
@@ -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
+70 -70
View File
@@ -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,
)
+47 -47
View File
@@ -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,
)
+1 -1
View File
@@ -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,
+63 -63
View File
@@ -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,
)