diff --git a/.gitignore b/.gitignore index 862d46c..f35bad0 100644 --- a/.gitignore +++ b/.gitignore @@ -229,5 +229,6 @@ pyrightconfig.json /downloaded_files /.local/ *.local.* +AGENTS.md .claude/ .playwright-mcp/ diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index f2bf678..b3202ef 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -3,7 +3,47 @@ import os from pathlib import Path import json -from typing import Any +from typing import Any, Dict + + +def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]: + """Validate advanced settings before persisting.""" + + mappings = values.get("PROWLARR_REMOTE_PATH_MAPPINGS") + if mappings is None: + return {"error": False, "values": values} + + if not isinstance(mappings, list): + return { + "error": True, + "message": "Remote path mappings must be a list", + "values": values, + } + + cleaned = [] + for entry in mappings: + if not isinstance(entry, dict): + continue + + host = str(entry.get("host", "") or "").strip().lower() + remote_path = str(entry.get("remotePath", "") or "").strip() + local_path = str(entry.get("localPath", "") or "").strip() + + if not host or not remote_path or not local_path: + continue + + if not local_path.startswith("/"): + return { + "error": True, + "message": "Local Path must be an absolute path", + "values": values, + } + + cleaned.append({"host": host, "remotePath": remote_path, "localPath": local_path}) + + values["PROWLARR_REMOTE_PATH_MAPPINGS"] = cleaned + return {"error": False, "values": values} + from shelfmark.config import env from shelfmark.config.booklore_settings import ( @@ -68,6 +108,7 @@ from shelfmark.core.settings_registry import ( SelectField, MultiSelectField, OrderableListField, + TableField, HeadingField, ActionButton, ) @@ -1125,6 +1166,47 @@ def mirror_settings(): def advanced_settings(): """Advanced settings for power users.""" return [ + HeadingField( + key="remote_path_mappings_heading", + title="Remote Path Mappings", + description="Map download client paths to paths inside Shelfmark. Needed when volume mounts differ between containers.", + ), + TableField( + key="PROWLARR_REMOTE_PATH_MAPPINGS", + label="Path Mappings", + columns=[ + { + "key": "host", + "label": "Client", + "type": "select", + "options": [ + {"value": "qbittorrent", "label": "qBittorrent"}, + {"value": "transmission", "label": "Transmission"}, + {"value": "deluge", "label": "Deluge"}, + {"value": "rtorrent", "label": "rTorrent"}, + {"value": "nzbget", "label": "NZBGet"}, + {"value": "sabnzbd", "label": "SABnzbd"}, + ], + "defaultValue": "qbittorrent", + }, + { + "key": "remotePath", + "label": "Remote Path", + "type": "path", + "placeholder": "/downloads", + }, + { + "key": "localPath", + "label": "Local Path", + "type": "path", + "placeholder": "/data/downloads", + }, + ], + default=[], + add_label="Add Mapping", + empty_message="No mappings configured.", + env_supported=False, + ), TextField( key="CUSTOM_SCRIPT", label="Custom Script Path", @@ -1227,3 +1309,6 @@ def advanced_settings(): callback=_clear_metadata_cache, ), ] + + +register_on_save("advanced", _on_save_advanced) diff --git a/shelfmark/core/path_mappings.py b/shelfmark/core/path_mappings.py new file mode 100644 index 0000000..29a7b8d --- /dev/null +++ b/shelfmark/core/path_mappings.py @@ -0,0 +1,102 @@ +"""Remote path mapping utilities. + +Used when an external download client reports a completed download path that does +not exist inside the Shelfmark runtime environment (commonly different Docker +volume mounts). + +A mapping rewrites a remote path prefix into a local path prefix. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Optional + + +@dataclass(frozen=True) +class RemotePathMapping: + host: str + remote_path: str + local_path: str + + +def _normalize_prefix(path: str) -> str: + normalized = str(path or "").strip() + if not normalized: + return "" + + normalized = normalized.replace("\\", "/") + + if normalized != "/": + normalized = normalized.rstrip("/") + + return normalized + + +def _normalize_host(host: str) -> str: + return str(host or "").strip().lower() + + +def parse_remote_path_mappings(value: Any) -> list[RemotePathMapping]: + if not value or not isinstance(value, list): + return [] + + mappings: list[RemotePathMapping] = [] + + for row in value: + if not isinstance(row, dict): + continue + + host = _normalize_host(row.get("host", "")) + remote_path = _normalize_prefix(row.get("remotePath", "")) + local_path = _normalize_prefix(row.get("localPath", "")) + + if not host or not remote_path or not local_path: + continue + + mappings.append(RemotePathMapping(host=host, remote_path=remote_path, local_path=local_path)) + + mappings.sort(key=lambda m: len(m.remote_path), reverse=True) + return mappings + + +def remap_remote_to_local(*, mappings: Iterable[RemotePathMapping], host: str, remote_path: str | Path) -> Path: + host_normalized = _normalize_host(host) + remote_normalized = _normalize_prefix(str(remote_path)) + + if not remote_normalized: + return Path(str(remote_path)) + + for mapping in mappings: + if _normalize_host(mapping.host) != host_normalized: + continue + + remote_prefix = _normalize_prefix(mapping.remote_path) + if not remote_prefix: + continue + + if remote_normalized == remote_prefix or remote_normalized.startswith(remote_prefix + "/"): + remainder = remote_normalized[len(remote_prefix) :] + local_prefix = _normalize_prefix(mapping.local_path) + + if remainder.startswith("/"): + remainder = remainder[1:] + + return Path(local_prefix) / remainder if remainder else Path(local_prefix) + + return Path(remote_normalized) + + +def get_client_host_identifier(client: Any) -> Optional[str]: + """Return a stable identifier used by the mapping UI. + + Sonarr uses the download client's configured host. Shelfmark currently uses + the download client 'name' (e.g. qbittorrent, sabnzbd). + """ + + name = getattr(client, "name", None) + if isinstance(name, str) and name.strip(): + return name.strip().lower() + + return None diff --git a/shelfmark/core/settings_registry.py b/shelfmark/core/settings_registry.py index 5c583fb..282c37d 100644 --- a/shelfmark/core/settings_registry.py +++ b/shelfmark/core/settings_registry.py @@ -94,6 +94,20 @@ class OrderableListField(FieldBase): default: List[Dict[str, Any]] = field(default_factory=list) +@dataclass +class TableField(FieldBase): + """Editable table of structured rows.""" + + # Column definitions: [{key, label, type, placeholder?, options?, defaultValue?}, ...] + columns: Any = field(default_factory=list) # list or callable + + # Value format: list of objects + default: List[Dict[str, Any]] = field(default_factory=list) + + add_label: str = "Add" + empty_message: str = "" + + @dataclass class ActionButton: key: str # Action identifier @@ -535,6 +549,14 @@ def _parse_env_value(value: str, field: SettingsField) -> Any: except json.JSONDecodeError: logger.warning(f"Invalid JSON for {field.key}, using default") return field.default + elif isinstance(field, TableField): + # Parse JSON array: [{"col": "value"}, ...] + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, list) else field.default + except json.JSONDecodeError: + logger.warning(f"Invalid JSON for {field.key}, using default") + return field.default else: return value @@ -625,6 +647,11 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T # Support callable options for lazy evaluation (avoids circular imports) options = field.options() if callable(field.options) else field.options result["options"] = options + elif isinstance(field, TableField): + columns = field.columns() if callable(field.columns) else field.columns + result["columns"] = columns + result["addLabel"] = field.add_label + result["emptyMessage"] = field.empty_message elif isinstance(field, ActionButton): result["style"] = field.style result["description"] = field.description @@ -647,6 +674,11 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T value = [v.strip() for v in value.split(",") if v.strip()] else: value = [] + elif isinstance(field, TableField): + if value is None: + value = [] + elif not isinstance(value, list): + value = [] result["value"] = value if value is not None else "" result["fromEnv"] = is_value_from_env(field) diff --git a/shelfmark/download/outputs/folder.py b/shelfmark/download/outputs/folder.py index 1506336..e928d1b 100644 --- a/shelfmark/download/outputs/folder.py +++ b/shelfmark/download/outputs/folder.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import subprocess from dataclasses import dataclass from pathlib import Path @@ -127,19 +128,19 @@ def process_folder_output( step_name = f"stage_{prepared.output_plan.stage_action}" record_step(steps, step_name, source=str(temp_file), dest=str(prepared.output_plan.staging_dir)) - # Run custom script only for non-archive single files (matches legacy behavior) - if core_config.config.CUSTOM_SCRIPT and prepared.working_path.is_file() and not is_archive(prepared.working_path): - record_step(steps, "custom_script", script=str(core_config.config.CUSTOM_SCRIPT)) + def run_custom_script(script_path: str, target_path: Path, phase: str) -> bool: + record_step(steps, "custom_script", script=str(script_path), target=str(target_path), phase=phase) log_plan_steps(task.task_id, steps) logger.info( - "Task %s: running custom script %s on %s", + "Task %s: running custom script %s on %s (%s)", task.task_id, - core_config.config.CUSTOM_SCRIPT, - prepared.working_path, + script_path, + target_path, + phase, ) try: result = subprocess.run( - [core_config.config.CUSTOM_SCRIPT, str(prepared.working_path)], + [script_path, str(target_path)], check=True, timeout=300, # 5 minute timeout capture_output=True, @@ -147,26 +148,19 @@ def process_folder_output( ) if result.stdout: logger.debug("Task %s: custom script stdout: %s", task.task_id, result.stdout.strip()) + return True except FileNotFoundError: - logger.error("Task %s: custom script not found: %s", task.task_id, core_config.config.CUSTOM_SCRIPT) - status_callback("error", f"Custom script not found: {core_config.config.CUSTOM_SCRIPT}") - return None + logger.error("Task %s: custom script not found: %s", task.task_id, script_path) + status_callback("error", f"Custom script not found: {script_path}") + return False except PermissionError: - logger.error( - "Task %s: custom script not executable: %s", - task.task_id, - core_config.config.CUSTOM_SCRIPT, - ) - status_callback("error", f"Custom script not executable: {core_config.config.CUSTOM_SCRIPT}") - return None + logger.error("Task %s: custom script not executable: %s", task.task_id, script_path) + status_callback("error", f"Custom script not executable: {script_path}") + return False except subprocess.TimeoutExpired: - logger.error( - "Task %s: custom script timed out after 300s: %s", - task.task_id, - core_config.config.CUSTOM_SCRIPT, - ) + logger.error("Task %s: custom script timed out after 300s: %s", task.task_id, script_path) status_callback("error", "Custom script timed out") - return None + return False except subprocess.CalledProcessError as e: stderr = e.stderr.strip() if e.stderr else "No error output" logger.error( @@ -176,7 +170,9 @@ def process_folder_output( stderr, ) status_callback("error", f"Custom script failed: {stderr[:100]}") - return None + return False + + # Custom script is run post-transfer (see below). # If we staged a copy into TMP_DIR (e.g. for custom script), transfer from the staged # path and disable hardlinking for this transfer. @@ -250,6 +246,25 @@ def process_folder_output( op_label.lower(), ) + # Run custom script once per successful task, after transfer. + if core_config.config.CUSTOM_SCRIPT: + if len(final_paths) == 1: + target_path = final_paths[0] + else: + try: + target_path = Path(os.path.commonpath([str(p.parent) for p in final_paths])) + except ValueError: + target_path = plan.destination + + if not run_custom_script(core_config.config.CUSTOM_SCRIPT, target_path, phase="post_transfer"): + cleanup_output_staging( + prepared.output_plan, + prepared.working_path, + task, + prepared.cleanup_paths, + ) + return None + cleanup_output_staging( prepared.output_plan, prepared.working_path, diff --git a/shelfmark/release_sources/prowlarr/clients/deluge.py b/shelfmark/release_sources/prowlarr/clients/deluge.py index aed55ee..5c6ef01 100644 --- a/shelfmark/release_sources/prowlarr/clients/deluge.py +++ b/shelfmark/release_sources/prowlarr/clients/deluge.py @@ -3,7 +3,7 @@ This implementation talks to Deluge via the Web UI JSON-RPC API (``/json``). Why Web UI API instead of daemon RPC (port 58846)? -- Matches the approach used by common automation apps (e.g. Sonarr/Radarr) +- Matches the approach used by common automation apps - Avoids requiring Deluge daemon ``auth`` file credentials (username/password) Requirements: @@ -91,7 +91,7 @@ class DelugeClient(DownloadClient): self._connected = False self._rpc_id = 0 - self._category = str(config.get("DELUGE_CATEGORY", "cwabd") or "cwabd") + self._category = str(config.get("DELUGE_CATEGORY", "books") or "books") def _next_rpc_id(self) -> int: self._rpc_id += 1 @@ -288,6 +288,7 @@ class DelugeClient(DownloadClient): file_path = None if complete: + # Output path is save_path + torrent name file_path = self._build_path( str(status.get("save_path", "")), str(status.get("name", "")), diff --git a/shelfmark/release_sources/prowlarr/clients/nzbget.py b/shelfmark/release_sources/prowlarr/clients/nzbget.py index 51733b9..74a464d 100644 --- a/shelfmark/release_sources/prowlarr/clients/nzbget.py +++ b/shelfmark/release_sources/prowlarr/clients/nzbget.py @@ -47,7 +47,7 @@ class NZBGetClient(DownloadClient): return client == "nzbget" and bool(url) @with_retry() - def _rpc_call(self, method: str, params: list = None) -> Any: + def _rpc_call(self, method: str, params: Optional[list] = None) -> Any: """ Make a JSON-RPC call to NZBGet. @@ -233,6 +233,15 @@ class NZBGetClient(DownloadClient): dest_dir = item.get("DestDir", "") or None file_path = final_dir or dest_dir # Use FinalDir if available + # Normalize for consistent downstream use. + if isinstance(file_path, str) and file_path: + import os + + file_path = os.path.normpath(file_path) + else: + file_path = None + + if "SUCCESS" in status: return DownloadStatus( progress=100, @@ -275,7 +284,7 @@ class NZBGetClient(DownloadClient): return False if delete_files: - # Sonarr uses HistoryDelete for NZBGet; keep that as a fallback for + # Keep HistoryDelete as a fallback for # older NZBGet versions where HistoryFinalDelete may not exist. commands = ["GroupFinalDelete", "HistoryFinalDelete", "HistoryDelete"] else: diff --git a/shelfmark/release_sources/prowlarr/clients/qbittorrent.py b/shelfmark/release_sources/prowlarr/clients/qbittorrent.py index e2e1a25..0e936c3 100644 --- a/shelfmark/release_sources/prowlarr/clients/qbittorrent.py +++ b/shelfmark/release_sources/prowlarr/clients/qbittorrent.py @@ -2,7 +2,7 @@ import time from types import SimpleNamespace -from typing import List, Optional, Tuple +from typing import Optional, Tuple from shelfmark.core.config import config from shelfmark.core.logger import setup_logger @@ -34,6 +34,55 @@ def _hashes_match(hash1: str, hash2: str) -> bool: class QBittorrentClient(DownloadClient): """qBittorrent download client.""" + def _is_torrent_loaded(self, torrent_hash: str) -> tuple[bool, Optional[str]]: + """Check whether qBittorrent has registered a torrent yet. + + Uses `/api/v2/torrents/properties?hash=`. + + Returns: + (loaded, error_message) + + Notes: + A false result with no error means "not loaded yet". + """ + import requests + + url = f"{self._base_url}/api/v2/torrents/properties" + params = {"hash": torrent_hash} + + try: + self._client.auth_log_in() + response = self._client._session.get(url, params=params, timeout=10) + + # Re-authenticate and retry once on 403 + if response.status_code == 403: + logger.debug("qBittorrent returned 403 for properties; re-authenticating and retrying") + self._client.auth_log_in() + response = self._client._session.get(url, params=params, timeout=10) + + if response.status_code == 403: + return False, "qBittorrent authentication failed (HTTP 403)" + + # qBittorrent returns 404/409-ish responses depending on version when missing. + if response.status_code == 404: + return False, None + + response.raise_for_status() + return True, None + except requests.exceptions.HTTPError as e: + status = getattr(getattr(e, "response", None), "status_code", None) + if status == 404: + return False, None + if status: + return False, f"qBittorrent API request failed (HTTP {status})" + return False, "qBittorrent API request failed" + except requests.exceptions.ConnectionError: + return False, f"Cannot connect to qBittorrent at {self._base_url}" + except requests.exceptions.Timeout: + return False, f"qBittorrent request timed out at {self._base_url}" + except Exception as e: + return False, f"qBittorrent API error: {type(e).__name__}: {e}" + protocol = "torrent" name = "qbittorrent" @@ -52,37 +101,103 @@ class QBittorrentClient(DownloadClient): username=config.get("QBITTORRENT_USERNAME", ""), password=config.get("QBITTORRENT_PASSWORD", ""), ) - self._category = config.get("QBITTORRENT_CATEGORY", "cwabd") + self._category = config.get("QBITTORRENT_CATEGORY", "books") - def _get_torrents_info(self, torrent_hash: Optional[str] = None) -> List: - """Get torrent info using GET (per API spec for read operations).""" + + def _get_torrents_info( + self, torrent_hash: Optional[str] = None + ) -> tuple[list[SimpleNamespace], Optional[str]]: + """Get torrent info using GET. + + Behaviors: + - Retry once on HTTP 403 by re-authenticating. + - Keep "API/auth/connect" errors distinct from "torrent missing". + - If a hash-specific query returns empty, fall back to listing by category + and matching locally. + + Returns: + (torrents, error_message) + """ import requests - try: + url = f"{self._base_url}/api/v2/torrents/info" + + def do_request(params: dict[str, str]) -> requests.Response: # Ensure session is authenticated before using it directly self._client.auth_log_in() + return self._client._session.get(url, params=params, timeout=10) + + def parse_response( + response: requests.Response, + *, + request_params: dict[str, str], + ) -> tuple[list[SimpleNamespace], Optional[str]]: + if response.status_code == 403: + logger.debug("qBittorrent returned 403; re-authenticating and retrying") + self._client.auth_log_in() + response = self._client._session.get(url, params=request_params, timeout=10) + + if response.status_code == 403: + logger.warning("qBittorrent authentication failed (HTTP 403)") + return [], "qBittorrent authentication failed (HTTP 403)" - params = {"hashes": torrent_hash} if torrent_hash else {} - response = self._client._session.get( - f"{self._base_url}/api/v2/torrents/info", - params=params, - timeout=10, - ) response.raise_for_status() torrents = response.json() - return [SimpleNamespace(**t) for t in torrents] + return [SimpleNamespace(**t) for t in torrents], None + + try: + primary_params: dict[str, str] = {} + if torrent_hash: + primary_params["hashes"] = torrent_hash + + response = do_request(primary_params) + torrents, error = parse_response(response, request_params=primary_params) + if error: + return [], error + + if torrent_hash and not torrents: + # Fallback 1: list by configured category + category_params: dict[str, str] = {} + if self._category: + category_params["category"] = self._category + + category_response = do_request(category_params) + category_torrents, category_error = parse_response( + category_response, request_params=category_params + ) + if category_error: + return [], category_error + + if category_torrents: + return category_torrents, None + + # Fallback 2: list everything (handles per-task categories like audiobooks) + all_response = do_request({}) + all_torrents, all_error = parse_response(all_response, request_params={}) + if all_error: + return [], all_error + + return all_torrents, None + + return torrents, None + except requests.exceptions.HTTPError as e: - if e.response is not None and e.response.status_code == 403: - logger.warning("qBittorrent auth failed - check credentials") - else: - logger.warning(f"qBittorrent API error: {e}") - return [] + status = getattr(getattr(e, "response", None), "status_code", None) + if status: + logger.warning(f"qBittorrent API error (HTTP {status}): {e}") + return [], f"qBittorrent API request failed (HTTP {status})" + + logger.warning(f"qBittorrent API error: {e}") + return [], "qBittorrent API request failed" except requests.exceptions.ConnectionError: logger.warning(f"Cannot connect to qBittorrent at {self._base_url}") - return [] + return [], f"Cannot connect to qBittorrent at {self._base_url}" + except requests.exceptions.Timeout: + logger.warning(f"qBittorrent request timed out at {self._base_url}") + return [], f"qBittorrent request timed out at {self._base_url}" except Exception as e: logger.debug(f"Failed to get torrents info: {e}") - return [] + return [], f"qBittorrent API error: {type(e).__name__}: {e}" @staticmethod def is_configured() -> bool: @@ -154,13 +269,16 @@ class QBittorrentClient(DownloadClient): if not expected_hash: raise Exception("Could not determine torrent hash from URL") - # Wait for torrent to appear in client + # Wait for torrent to appear in client. + # Use `/torrents/properties?hash=` rather than relying on `torrents/info` + # listing being immediately consistent. for _ in range(10): - torrents = self._get_torrents_info(expected_hash) - for t in torrents: - if _hashes_match(t.hash, expected_hash): - logger.info(f"Added torrent: {t.hash}") - return t.hash.lower() + loaded, error = self._is_torrent_loaded(expected_hash) + if error: + logger.debug(f"qBittorrent add_download: {error}") + if loaded: + logger.info(f"Added torrent: {expected_hash}") + return expected_hash.lower() time.sleep(0.5) # Client said Ok, trust it @@ -183,10 +301,21 @@ class QBittorrentClient(DownloadClient): Current download status. """ try: - torrents = self._get_torrents_info(download_id) - torrent = next((t for t in torrents if _hashes_match(t.hash, download_id)), None) + torrents, error = self._get_torrents_info(download_id) + if error: + return DownloadStatus.error(error) + + torrent = next( + ( + t + for t in torrents + if isinstance(getattr(t, "hash", None), str) + and _hashes_match(getattr(t, "hash"), download_id) + ), + None, + ) if not torrent: - return DownloadStatus.error("Torrent not found") + return DownloadStatus.error("Torrent not found in qBittorrent") # Map qBittorrent states to our states and user-friendly messages state_info = { @@ -211,37 +340,37 @@ class QBittorrentClient(DownloadClient): "unknown": ("unknown", "Unknown state"), } - state, message = state_info.get(torrent.state, ("unknown", torrent.state)) + torrent_state = getattr(torrent, "state", "unknown") + state, message = state_info.get(torrent_state, ("unknown", str(torrent_state))) + + torrent_progress = getattr(torrent, "progress", 0.0) # Don't mark complete while files are being moved to final location # (qBittorrent moves files from incomplete → complete folder) - complete = torrent.progress >= 1.0 and torrent.state != "moving" + complete = torrent_progress >= 1.0 and torrent_state != "moving" # For active downloads without a special message, leave message as None # so the handler can build the progress message if complete: message = "Complete" - eta = torrent.eta if 0 < torrent.eta < 604800 else None + torrent_eta = getattr(torrent, "eta", 0) + eta = torrent_eta if isinstance(torrent_eta, int) and 0 < torrent_eta < 604800 else None # Get file path for completed downloads file_path = None if complete: - if getattr(torrent, 'content_path', ''): - file_path = torrent.content_path - else: - # Fallback for Amarr which doesn't populate content_path - file_path = self._build_path( - getattr(torrent, 'save_path', ''), - getattr(torrent, 'name', ''), - ) + file_path = self._resolve_completed_download_path(torrent) + + torrent_speed = getattr(torrent, "dlspeed", None) + torrent_speed = torrent_speed if isinstance(torrent_speed, int) else None return DownloadStatus( - progress=torrent.progress * 100, + progress=float(torrent_progress) * 100, state="complete" if complete else state, message=message, complete=complete, file_path=file_path, - download_speed=torrent.dlspeed, + download_speed=torrent_speed, eta=eta, ) except Exception as e: @@ -272,23 +401,123 @@ class QBittorrentClient(DownloadClient): return False def get_download_path(self, download_id: str) -> Optional[str]: - """Get the path where torrent files are located.""" + """Get the path where torrent files are located. + + Prefer `content_path` when available. + + When `content_path` is missing (commonly with qBittorrent-like emulators such + as Amarr), derive the path using: + - `/api/v2/torrents/properties?hash=` for `save_path` + - `/api/v2/torrents/files?hash=` for the first file name + - join `save_path` with the torrent's top-level directory + """ + import os + try: - torrents = self._get_torrents_info(download_id) - torrent = next((t for t in torrents if _hashes_match(t.hash, download_id)), None) + torrents, error = self._get_torrents_info(download_id) + if error: + logger.debug(f"qBittorrent get_download_path: {error}") + return None + + torrent = next( + ( + t + for t in torrents + if isinstance(getattr(t, "hash", None), str) + and _hashes_match(getattr(t, "hash"), download_id) + ), + None, + ) if not torrent: return None - # Prefer content_path, fall back to save_path/name (for Amarr compatibility) - if getattr(torrent, 'content_path', ''): - return torrent.content_path - return self._build_path( - getattr(torrent, 'save_path', ''), - getattr(torrent, 'name', ''), - ) + + return self._resolve_completed_download_path(torrent) except Exception as e: self._log_error("get_download_path", e, level="debug") return None + def _resolve_completed_download_path(self, torrent: SimpleNamespace) -> Optional[str]: + """Resolve the completed path for a torrent. + + Centralizes the logic shared by `get_status()` and `get_download_path()`: + - accept `content_path` only when it's not equal to `save_path` + - otherwise derive via properties+files + - finally fall back to `save_path + name` + """ + + # Prefer content_path, but treat content_path == save_path as invalid. + content_path = getattr(torrent, "content_path", "") + save_path = getattr(torrent, "save_path", "") + if content_path and (not save_path or str(content_path) != str(save_path)): + return str(content_path) + + download_id = getattr(torrent, "hash", "") + if isinstance(download_id, str) and download_id: + derived = self._derive_download_path_from_files(download_id) + if derived: + return derived + + # Legacy fallback: save_path + name (for older clients/emulators) + return self._build_path( + getattr(torrent, "save_path", ""), + getattr(torrent, "name", ""), + ) + + def _derive_download_path_from_files(self, download_id: str) -> Optional[str]: + """Derive completed download path using `/torrents/properties` + `/torrents/files`. + + This mirrors how common automation apps derive the path when + `content_path` isn't provided. + """ + import os + import requests + + def get_with_auth(url: str, params: dict[str, str]) -> requests.Response: + self._client.auth_log_in() + resp = self._client._session.get(url, params=params, timeout=10) + if resp.status_code == 403: + logger.debug("qBittorrent returned 403; re-authenticating and retrying") + self._client.auth_log_in() + resp = self._client._session.get(url, params=params, timeout=10) + return resp + + try: + properties_url = f"{self._base_url}/api/v2/torrents/properties" + files_url = f"{self._base_url}/api/v2/torrents/files" + + props_resp = get_with_auth(properties_url, {"hash": download_id}) + if props_resp.status_code == 404: + return None + props_resp.raise_for_status() + props = props_resp.json() if isinstance(props_resp.json(), dict) else {} + + save_path = props.get("save_path") or props.get("savePath") or "" + if not isinstance(save_path, str) or not save_path: + return None + + files_resp = get_with_auth(files_url, {"hash": download_id}) + if files_resp.status_code == 404: + return None + files_resp.raise_for_status() + files = files_resp.json() if isinstance(files_resp.json(), list) else [] + if not files: + return None + + first_name = files[0].get("name") if isinstance(files[0], dict) else None + if not isinstance(first_name, str) or not first_name: + return None + + # Get the first path segment (qBittorrent returns '/' even on Windows). + first_name_norm = first_name.replace("\\", "/") + top_level = first_name_norm.split("/", 1)[0] + if not top_level: + return None + + return os.path.normpath(os.path.join(save_path, top_level)) + except Exception as e: + logger.debug(f"qBittorrent could not derive path from files: {type(e).__name__}: {e}") + return None + def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]: """Check if a torrent for this URL already exists in qBittorrent.""" try: @@ -296,10 +525,23 @@ class QBittorrentClient(DownloadClient): if not torrent_info.info_hash: return None - torrents = self._get_torrents_info(torrent_info.info_hash) - torrent = next((t for t in torrents if _hashes_match(t.hash, torrent_info.info_hash)), None) - if torrent: - return (torrent.hash.lower(), self.get_status(torrent.hash.lower())) + torrents, error = self._get_torrents_info(torrent_info.info_hash) + if error: + logger.debug(f"qBittorrent find_existing: {error}") + return None + + torrent = next( + ( + t + for t in torrents + if isinstance(getattr(t, "hash", None), str) + and _hashes_match(getattr(t, "hash"), torrent_info.info_hash) + ), + None, + ) + if torrent and isinstance(getattr(torrent, "hash", None), str): + torrent_hash = getattr(torrent, "hash") + return (torrent_hash.lower(), self.get_status(torrent_hash.lower())) return None except Exception as e: diff --git a/shelfmark/release_sources/prowlarr/clients/rtorrent.py b/shelfmark/release_sources/prowlarr/clients/rtorrent.py index 8c8ffbd..b23b31b 100644 --- a/shelfmark/release_sources/prowlarr/clients/rtorrent.py +++ b/shelfmark/release_sources/prowlarr/clients/rtorrent.py @@ -66,7 +66,7 @@ class RTorrentClient(DownloadClient): except Exception as e: return False, f"Connection failed: {str(e)}" - def add_download(self, url: str, name: str, category: str = None) -> str: + def add_download(self, url: str, name: str, category: Optional[str] = None) -> str: """ Add torrent by URL (magnet or .torrent). @@ -142,6 +142,7 @@ class RTorrentClient(DownloadClient): "d.up.rate=", "d.custom1=", "d.complete=", + "d.up.total=", ) logger.debug(f"Fetched torrent status from rTorrent for: {download_id} - {torrent_list}") if not torrent_list: @@ -165,6 +166,13 @@ class RTorrentClient(DownloadClient): complete, ) = torrent + try: + state = int(state) + except Exception: + state = 0 + + complete = bool(complete) + if bytes_total > 0: progress = (bytes_downloaded / bytes_total) * 100 else: @@ -173,8 +181,11 @@ class RTorrentClient(DownloadClient): bytes_left = max(0, bytes_total - bytes_downloaded) state_map = { - 0: ("stopped", "Stopped"), - 1: ("started", "Started"), + 0: ("paused", "Paused"), + 1: ("downloading", "Downloading"), + 2: ("downloading", "Downloading"), + 3: ("downloading", "Downloading"), + 4: ("seeding", "Seeding"), } state_str, message = state_map.get(state, ("unknown", "Unknown state")) @@ -281,9 +292,13 @@ class RTorrentClient(DownloadClient): return "/downloads" def _get_torrent_path(self, download_id: str) -> Optional[str]: - """Get the file path of a torrent by hash.""" + """Get the file path of a torrent by hash. + + Uses `d.base_path` for the item output path. In the xmlrpc interface + this corresponds to `d.get_base_path()`. + """ try: - base_path = self._rpc.d.directory(download_id) + base_path = self._rpc.d.get_base_path(download_id) return base_path if base_path else None except Exception: return None diff --git a/shelfmark/release_sources/prowlarr/clients/sabnzbd.py b/shelfmark/release_sources/prowlarr/clients/sabnzbd.py index e046a28..dabd58f 100644 --- a/shelfmark/release_sources/prowlarr/clients/sabnzbd.py +++ b/shelfmark/release_sources/prowlarr/clients/sabnzbd.py @@ -67,6 +67,33 @@ def _parse_speed(slot: dict) -> Optional[int]: class SABnzbdClient(DownloadClient): """SABnzbd download client using REST API.""" + @staticmethod + def _resolve_completed_storage_path(storage: str, title: str) -> str: + """Normalize SABnzbd's `storage` into a stable "job root" folder. + + Walks up parent directories looking for a directory named exactly like the + job `title`. + + This helps when SABnzbd reports a nested path (e.g. sorting/post-processing) + but we want the root folder for the completed job. + """ + from pathlib import Path + + storage = storage or "" + title = (title or "").strip() + if not storage or not title: + return storage + + # SAB returns absolute paths; don't require existence on disk. + path = Path(storage) + best_match: Path | None = None + + for parent in [path, *path.parents]: + if parent.name == title: + best_match = parent + + return str(best_match) if best_match is not None else storage + protocol = "usenet" name = "sabnzbd" @@ -82,7 +109,7 @@ class SABnzbdClient(DownloadClient): self.url = url.rstrip("/") self.api_key = api_key - self._category = config.get("SABNZBD_CATEGORY", "cwabd") + self._category = config.get("SABNZBD_CATEGORY", "books") @staticmethod def is_configured() -> bool: @@ -93,7 +120,7 @@ class SABnzbdClient(DownloadClient): return client == "sabnzbd" and bool(url) and bool(api_key) @with_retry() - def _api_call(self, mode: str, params: dict = None) -> Any: + def _api_call(self, mode: str, params: Optional[dict] = None) -> Any: """ Make an API call to SABnzbd. @@ -142,7 +169,7 @@ class SABnzbdClient(DownloadClient): except Exception as e: return False, f"Connection failed: {str(e)}" - def add_download(self, url: str, name: str, category: str = None) -> str: + def add_download(self, url: str, name: str, category: Optional[str] = None) -> str: """ Add NZB by URL. @@ -248,12 +275,15 @@ class SABnzbdClient(DownloadClient): logger.debug(f"SABnzbd history: {download_id} status={status_text} storage='{storage}'") if status_text == "COMPLETED": + title = slot.get("name") or slot.get("nzb_name") or "" + resolved_storage = self._resolve_completed_storage_path(storage, title) + return DownloadStatus( progress=100, state="complete", message="Complete", complete=True, - file_path=storage, + file_path=resolved_storage, ) elif status_text == "FAILED": fail_message = slot.get("fail_message", "Download failed") diff --git a/shelfmark/release_sources/prowlarr/clients/transmission.py b/shelfmark/release_sources/prowlarr/clients/transmission.py index a7dc2be..dda426d 100644 --- a/shelfmark/release_sources/prowlarr/clients/transmission.py +++ b/shelfmark/release_sources/prowlarr/clients/transmission.py @@ -6,6 +6,7 @@ Uses the transmission-rpc library to communicate with Transmission's RPC API. from typing import Optional, Tuple + from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.release_sources.prowlarr.clients import ( @@ -49,7 +50,7 @@ class TransmissionClient(DownloadClient): username=username if username else None, password=password if password else None, ) - self._category = config.get("TRANSMISSION_CATEGORY", "cwabd") + self._category = config.get("TRANSMISSION_CATEGORY", "books") @staticmethod def is_configured() -> bool: @@ -67,7 +68,7 @@ class TransmissionClient(DownloadClient): except Exception as e: return False, f"Connection failed: {str(e)}" - def add_download(self, url: str, name: str, category: str = None) -> str: + def add_download(self, url: str, name: str, category: Optional[str] = None) -> str: """ Add torrent by URL (magnet or .torrent). @@ -83,21 +84,21 @@ class TransmissionClient(DownloadClient): Exception: If adding fails. """ try: - category = category or self._category + resolved_category = category or self._category or "" torrent_info = extract_torrent_info(url) if torrent_info.torrent_data: torrent = self._client.add_torrent( torrent=torrent_info.torrent_data, - labels=[category], + labels=[resolved_category] if resolved_category else None, ) else: # Use magnet URL if available, otherwise original URL add_url = torrent_info.magnet_url or url torrent = self._client.add_torrent( torrent=add_url, - labels=[category], + labels=[resolved_category] if resolved_category else None, ) torrent_hash = torrent.hashString.lower() @@ -163,9 +164,13 @@ class TransmissionClient(DownloadClient): # Get file path for completed downloads file_path = None if complete: + # Output path is downloadDir + torrent name (with ':' replaced) + torrent_name = getattr(torrent, 'name', '') + if isinstance(torrent_name, str): + torrent_name = torrent_name.replace(':', '_') file_path = self._build_path( getattr(torrent, 'download_dir', ''), - getattr(torrent, 'name', ''), + torrent_name, ) return DownloadStatus( @@ -219,11 +224,14 @@ class TransmissionClient(DownloadClient): Content path (file or directory), or None. """ try: - torrent = self._client.get_torrent(download_id) - return self._build_path( - getattr(torrent, 'download_dir', ''), - getattr(torrent, 'name', ''), - ) + torrent = self._client.get_torrent(download_id) + torrent_name = getattr(torrent, 'name', '') + if isinstance(torrent_name, str): + torrent_name = torrent_name.replace(':', '_') + return self._build_path( + getattr(torrent, 'download_dir', ''), + torrent_name, + ) except Exception as e: self._log_error("get_download_path", e, level="debug") return None diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index 85bedec..487db3f 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -53,7 +53,8 @@ def _diagnose_path_issue(path: str) -> str: # 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"Ensure both containers have matching volume mounts for this directory, " + f"or configure Remote Path Mappings in Settings > Advanced." ) @@ -295,8 +296,32 @@ class ProwlarrHandler(DownloadHandler): # Check for error state if status.state == DownloadState.ERROR: - # "Torrent not found" is often transient - the client may not have indexed it yet - if "not found" in (status.message or "").lower(): + 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( @@ -307,12 +332,14 @@ class ProwlarrHandler(DownloadHandler): if cancel_flag.wait(timeout=POLL_INTERVAL): break continue - # Exhausted retries + 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 @@ -364,16 +391,41 @@ class ProwlarrHandler(DownloadHandler): ) return None - # Verify the path actually exists in our filesystem + # Apply remote path mappings (client path -> shelfmark container path) + from shelfmark.core.path_mappings import ( + get_client_host_identifier, + parse_remote_path_mappings, + remap_remote_to_local, + ) + source_path_obj = Path(source_path) if not source_path_obj.exists(): - hint = _diagnose_path_issue(source_path) - logger.error( - f"Download path does not exist: {source_path}. " - f"Client: {client.name}, ID: {download_id}. {hint}" + host = get_client_host_identifier(client) or "" + mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", []) + mappings = parse_remote_path_mappings(mapping_value) + remapped = remap_remote_to_local( + mappings=mappings, + host=host, + remote_path=source_path_obj, ) - status_callback("error", hint) - return None + + if remapped != source_path_obj and remapped.exists(): + logger.info( + "Remapped download path for %s (%s): %s -> %s", + client.name, + download_id, + source_path_obj, + remapped, + ) + source_path_obj = remapped + else: + hint = _diagnose_path_issue(source_path) + logger.error( + f"Download path does not exist: {source_path}. " + f"Client: {client.name}, ID: {download_id}. {hint}" + ) + status_callback("error", hint) + return None result = self._handle_completed_file( source_path=source_path_obj, diff --git a/src/frontend/src/components/settings/SettingsContent.tsx b/src/frontend/src/components/settings/SettingsContent.tsx index abb4dfe..70e204b 100644 --- a/src/frontend/src/components/settings/SettingsContent.tsx +++ b/src/frontend/src/components/settings/SettingsContent.tsx @@ -14,6 +14,7 @@ import { ActionButtonConfig, HeadingFieldConfig, ShowWhenCondition, + TableFieldConfig, } from '../../types/settings'; import { FieldWrapper } from './shared'; import { @@ -26,6 +27,7 @@ import { OrderableListField, ActionButton, HeadingField, + TableField, } from './fields'; interface SettingsContentProps { @@ -207,6 +209,15 @@ const renderField = ( ); case 'ActionButton': return ; + case 'TableField': + return ( + []) ?? []} + onChange={onChange} + disabled={isDisabled} + /> + ); case 'HeadingField': return ; default: diff --git a/src/frontend/src/components/settings/fields/TableField.tsx b/src/frontend/src/components/settings/fields/TableField.tsx new file mode 100644 index 0000000..b4f3897 --- /dev/null +++ b/src/frontend/src/components/settings/fields/TableField.tsx @@ -0,0 +1,208 @@ +import { useMemo } from 'react'; +import { TableFieldConfig, TableFieldColumn } from '../../../types/settings'; +import { DropdownList } from '../../DropdownList'; + +interface TableFieldProps { + field: TableFieldConfig; + value: Record[]; + onChange: (value: Record[]) => void; + disabled?: boolean; +} + +function defaultCellValue(column: TableFieldColumn): unknown { + if (column.defaultValue !== undefined) { + return column.defaultValue; + } + if (column.type === 'checkbox') { + return false; + } + return ''; +} + +function normalizeRows(rows: Record[], columns: TableFieldColumn[]): Record[] { + return (rows ?? []).map((row) => { + const normalized: Record = { ...row }; + for (const col of columns) { + if (!(col.key in normalized)) { + normalized[col.key] = defaultCellValue(col); + } + } + return normalized; + }); +} + +export const TableField = ({ field, value, onChange, disabled }: TableFieldProps) => { + const isDisabled = disabled ?? false; + + const columns = useMemo(() => field.columns ?? [], [field.columns]); + const rows = useMemo(() => normalizeRows(value ?? [], columns), [value, columns]); + + const updateCell = (rowIndex: number, key: string, cellValue: unknown) => { + const next = rows.map((row, idx) => (idx === rowIndex ? { ...row, [key]: cellValue } : row)); + onChange(next); + }; + + const addRow = () => { + const newRow: Record = {}; + columns.forEach((col) => { + newRow[col.key] = defaultCellValue(col); + }); + onChange([...(rows ?? []), newRow]); + }; + + const removeRow = (rowIndex: number) => { + const next = rows.filter((_, idx) => idx !== rowIndex); + onChange(next); + }; + + if (rows.length === 0) { + return ( +
+ {field.emptyMessage &&

{field.emptyMessage}

} + +
+ ); + } + + // Use minmax(0, ...) so the grid can shrink inside the settings modal. + const gridTemplate = 'sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)_minmax(0,1fr)_auto]'; + + return ( +
+
+ {columns.map((col) => ( +
+ {col.label} +
+ ))} +
+
+ +
+ {rows.map((row, rowIndex) => ( +
+ {columns.map((col) => { + const cellValue = row[col.key]; + + const mobileLabel =
{col.label}
; + + if (col.type === 'checkbox') { + return ( +
+ {mobileLabel} +
+ updateCell(rowIndex, col.key, e.target.checked)} + disabled={isDisabled} + className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500 + disabled:opacity-60 disabled:cursor-not-allowed" + /> +
+
+ ); + } + + if (col.type === 'select') { + const options = (col.options ?? []).map((opt) => ({ + value: String(opt.value), + label: opt.label, + description: opt.description, + })); + + return ( +
+ {mobileLabel} + {isDisabled ? ( +
+ {options.find((o) => o.value === String(cellValue ?? ''))?.label || 'Select...'} +
+ ) : ( + updateCell(rowIndex, col.key, Array.isArray(val) ? val[0] : val)} + placeholder={col.placeholder || 'Select...'} + widthClassName="w-full" + /> + )} +
+ ); + } + + // text/path + return ( +
+ {mobileLabel} + updateCell(rowIndex, col.key, e.target.value)} + placeholder={col.placeholder} + disabled={isDisabled} + className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] + bg-[var(--bg-soft)] text-sm + focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500 + disabled:opacity-60 disabled:cursor-not-allowed + transition-colors" + /> +
+ ); + })} + +
+ +
+ +
+
+ ))} +
+ + +
+ ); +}; diff --git a/src/frontend/src/components/settings/fields/index.ts b/src/frontend/src/components/settings/fields/index.ts index 0589c43..ed3e701 100644 --- a/src/frontend/src/components/settings/fields/index.ts +++ b/src/frontend/src/components/settings/fields/index.ts @@ -6,4 +6,5 @@ export { SelectField } from './SelectField'; export { MultiSelectField } from './MultiSelectField'; export { OrderableListField } from './OrderableListField'; export { ActionButton } from './ActionButton'; +export { TableField } from './TableField'; export { HeadingField } from './HeadingField'; diff --git a/src/frontend/src/hooks/useSettings.ts b/src/frontend/src/hooks/useSettings.ts index f812452..b7a7efa 100644 --- a/src/frontend/src/hooks/useSettings.ts +++ b/src/frontend/src/hooks/useSettings.ts @@ -37,6 +37,11 @@ function getFieldValue(field: SettingsField): unknown { if (field.type === 'ActionButton' || field.type === 'HeadingField') { return undefined; } + + if (field.type === 'TableField') { + return (field as unknown as { value?: unknown }).value ?? []; + } + // All other fields have a value property return field.value ?? ''; } diff --git a/src/frontend/src/types/settings.ts b/src/frontend/src/types/settings.ts index c24e5c9..1d05f89 100644 --- a/src/frontend/src/types/settings.ts +++ b/src/frontend/src/types/settings.ts @@ -8,6 +8,7 @@ export type FieldType = | 'SelectField' | 'MultiSelectField' | 'OrderableListField' + | 'TableField' | 'ActionButton' | 'HeadingField'; @@ -118,6 +119,31 @@ export interface ActionButtonConfig extends BaseField { style: 'default' | 'primary' | 'danger'; } +export interface TableFieldColumnOption { + value: string; + label: string; + description?: string; +} + +export type TableFieldColumnType = 'text' | 'select' | 'checkbox' | 'path'; + +export interface TableFieldColumn { + key: string; + label: string; + type: TableFieldColumnType; + placeholder?: string; + options?: TableFieldColumnOption[]; + defaultValue?: string | boolean; +} + +export interface TableFieldConfig extends BaseField { + type: 'TableField'; + value: Record[]; + columns: TableFieldColumn[]; + addLabel?: string; + emptyMessage?: string; +} + export interface HeadingFieldConfig { key: string; type: 'HeadingField'; @@ -138,6 +164,7 @@ export type SettingsField = | SelectFieldConfig | MultiSelectFieldConfig | OrderableListFieldConfig + | TableFieldConfig | ActionButtonConfig | HeadingFieldConfig; diff --git a/tests/core/test_download_processing.py b/tests/core/test_download_processing.py index fd22ccb..2fb2267 100644 --- a/tests/core/test_download_processing.py +++ b/tests/core/test_download_processing.py @@ -333,6 +333,7 @@ class TestProcessDirectory: ) assert final_paths == [] + assert error is not None assert "format not supported" in error assert ".pdf" in error @@ -446,6 +447,7 @@ class TestProcessDirectory: ) assert final_paths == [] + assert error is not None assert "Move failed" in error # Directory should be cleaned up assert not directory.exists() @@ -517,6 +519,7 @@ class TestPostProcessDownload: status_callback=status_cb, ) + assert result is not None result_path = Path(result) assert "The Way of Kings" in result_path.name @@ -588,6 +591,7 @@ class TestPostProcessDownload: status_callback=status_cb, ) + assert result is not None result_path = Path(result) # Should go to ingest, not library assert result_path.parent == temp_dirs["ingest"] @@ -660,6 +664,7 @@ class TestPostProcessDownload: status_callback=status_cb, ) + assert result is not None result_path = Path(result) assert result_path.parent == audiobook_ingest @@ -704,7 +709,55 @@ class TestCustomScriptExecution: assert result is not None mock_run.assert_called_once() call_args = mock_run.call_args - assert call_args[0][0] == ["/path/to/script.sh", str(temp_file)] + result_path = Path(result) + assert call_args[0][0] == ["/path/to/script.sh", str(result_path)] + + def test_runs_custom_script_for_directory_download_once(self, temp_dirs): + """Runs custom script once after transferring a directory download.""" + from shelfmark.download.postprocess.router import post_process_download as _post_process_download + + download_dir = temp_dirs["staging"] / "release" + download_dir.mkdir() + (download_dir / "01.mp3").write_bytes(b"a") + (download_dir / "02.mp3").write_bytes(b"b") + + task = DownloadTask( + task_id="test-dir", + source="direct_download", + title="My Book", + author="An Author", + format="mp3", + search_mode=SearchMode.DIRECT, + content_type="audiobook", + ) + + status_cb = MagicMock() + cancel_flag = Event() + + with patch('shelfmark.core.config.config') as mock_config, \ + patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \ + patch('subprocess.run') as mock_run: + + mock_config.USE_BOOK_TITLE = False + mock_config.CUSTOM_SCRIPT = "/path/to/script.sh" + _sync_core_config(mock_config, mock_config) + mock_config.get = _mock_destination_config(temp_dirs["ingest"], {"FILE_ORGANIZATION_AUDIOBOOK": "none"}) + _sync_core_config(mock_config, mock_config) + + mock_run.return_value = MagicMock(stdout="", returncode=0) + + result = _post_process_download( + temp_file=download_dir, + task=task, + cancel_flag=cancel_flag, + status_callback=status_cb, + ) + + assert result is not None + assert mock_run.call_count == 1 + script_args = mock_run.call_args[0][0] + assert script_args[0] == "/path/to/script.sh" + assert Path(script_args[1]) == temp_dirs["ingest"] def test_script_not_found_error(self, temp_dirs, sample_direct_task): """Returns error when script not found.""" diff --git a/tests/core/test_processing_integration.py b/tests/core/test_processing_integration.py index 94dfcc6..cdef52f 100644 --- a/tests/core/test_processing_integration.py +++ b/tests/core/test_processing_integration.py @@ -803,13 +803,11 @@ def test_custom_script_external_source_stages_copy_and_preserves_source(tmp_path # Original external file must be preserved. assert original.exists() - # Script should have run against a staged copy inside TMP. + # Script should have run against the final imported file. assert mock_run.call_count == 1 script_args = mock_run.call_args[0][0] assert script_args[0] == "/path/to/script.sh" - staged_path = Path(script_args[1]) - assert staging in staged_path.parents - assert staged_path != original + assert Path(script_args[1]) == result_path # Staging directory should be cleaned. assert list(staging.iterdir()) == [] diff --git a/tests/prowlarr/test_handler.py b/tests/prowlarr/test_handler.py index b8801e0..3a9ef25 100644 --- a/tests/prowlarr/test_handler.py +++ b/tests/prowlarr/test_handler.py @@ -105,6 +105,7 @@ class TestProwlarrHandlerDownloadErrors: assert result is None assert recorder.last_status == "error" + assert recorder.last_message is not None assert "cache" in recorder.last_message.lower() def test_download_fails_without_download_url(self): @@ -135,6 +136,7 @@ class TestProwlarrHandlerDownloadErrors: assert result is None assert recorder.last_status == "error" + assert recorder.last_message is not None assert "url" in recorder.last_message.lower() def test_download_fails_unknown_protocol(self): @@ -164,6 +166,7 @@ class TestProwlarrHandlerDownloadErrors: assert result is None assert recorder.last_status == "error" + assert recorder.last_message is not None assert "protocol" in recorder.last_message.lower() def test_download_fails_no_client_configured(self): @@ -199,6 +202,7 @@ class TestProwlarrHandlerDownloadErrors: assert result is None assert recorder.last_status == "error" + assert recorder.last_message is not None assert "client" in recorder.last_message.lower() @@ -270,6 +274,129 @@ class TestProwlarrHandlerExistingDownload: class TestProwlarrHandlerPolling: """Tests for download polling behavior.""" + def test_retries_torrent_not_found_errors(self): + """"Torrent not found" should be treated as transient.""" + with tempfile.TemporaryDirectory() as tmp_dir: + source_file = Path(tmp_dir) / "source" / "book.epub" + source_file.parent.mkdir(parents=True) + source_file.write_text("test content") + + staging_dir = Path(tmp_dir) / "staging" + staging_dir.mkdir() + + poll_count = [0] + + def mock_get_status(download_id): + poll_count[0] += 1 + if poll_count[0] <= 2: + return DownloadStatus( + progress=0, + state=DownloadState.ERROR, + message="Torrent not found in qBittorrent", + complete=False, + file_path=None, + ) + + return DownloadStatus( + progress=100, + state=DownloadState.COMPLETE, + message="Complete", + complete=True, + file_path=str(source_file), + ) + + mock_client = MagicMock() + mock_client.name = "qbittorrent" + mock_client.find_existing.return_value = None + mock_client.add_download.return_value = "download_id" + mock_client.get_status.side_effect = mock_get_status + mock_client.get_download_path.return_value = str(source_file) + + with patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), patch( + "shelfmark.download.staging.get_staging_dir", + return_value=staging_dir, + ), patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="poll-not-found-test", + source="prowlarr", + title="Test Book", + ) + 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 not None + assert poll_count[0] >= 3 + assert "resolving" in recorder.statuses + + def test_fails_fast_on_auth_errors(self): + """Auth/API errors should not be retried as "not found".""" + mock_client = MagicMock() + mock_client.name = "qbittorrent" + mock_client.find_existing.return_value = None + mock_client.add_download.return_value = "download_id" + mock_client.get_status.return_value = DownloadStatus( + progress=0, + state=DownloadState.ERROR, + message="qBittorrent authentication failed (HTTP 403)", + complete=False, + file_path=None, + ) + + with patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="poll-auth-fail-test", + source="prowlarr", + title="Test Book", + ) + 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 + assert recorder.last_status == "error" + assert "authentication failed" in (recorder.last_message or "").lower() + def test_polls_until_complete(self): """Test that handler polls until download is complete.""" with tempfile.TemporaryDirectory() as tmp_dir: diff --git a/tests/prowlarr/test_integration_clients.py b/tests/prowlarr/test_integration_clients.py index 263cde4..f800783 100644 --- a/tests/prowlarr/test_integration_clients.py +++ b/tests/prowlarr/test_integration_clients.py @@ -311,7 +311,7 @@ class TestTransmissionIntegration: # State should be a known value valid_states = {"downloading", "complete", "error", "seeding", "paused", "queued", "fetching_metadata"} - assert status.state in valid_states + assert status.state.value in valid_states # Complete should be boolean assert isinstance(status.complete, bool) @@ -396,7 +396,7 @@ class TestQBittorrentIntegration: assert 0 <= status.progress <= 100 valid_states = {"downloading", "complete", "error", "seeding", "paused", "queued", "fetching_metadata", "stalled"} - assert status.state in valid_states + assert status.state.value in valid_states assert isinstance(status.complete, bool) finally: @@ -480,7 +480,7 @@ class TestDelugeIntegration: assert 0 <= status.progress <= 100 valid_states = {"downloading", "complete", "error", "seeding", "paused", "queued", "fetching_metadata", "checking"} - assert status.state in valid_states + assert status.state.value in valid_states assert isinstance(status.complete, bool) finally: diff --git a/tests/prowlarr/test_nzbget_client.py b/tests/prowlarr/test_nzbget_client.py index 100fb14..785214c 100644 --- a/tests/prowlarr/test_nzbget_client.py +++ b/tests/prowlarr/test_nzbget_client.py @@ -595,7 +595,7 @@ class TestNZBGetClientRemove: assert calls[-1][1][0] == "GroupDelete" def test_remove_falls_back_to_history_delete(self, monkeypatch): - """If HistoryFinalDelete is unsupported, fall back to HistoryDelete (Sonarr behavior).""" + """If HistoryFinalDelete is unsupported, fall back to HistoryDelete.""" config_values = { "NZBGET_URL": "http://localhost:6789", "NZBGET_USERNAME": "nzbget", diff --git a/tests/prowlarr/test_qbittorrent_client.py b/tests/prowlarr/test_qbittorrent_client.py index 5cc51ca..6dde06e 100644 --- a/tests/prowlarr/test_qbittorrent_client.py +++ b/tests/prowlarr/test_qbittorrent_client.py @@ -46,9 +46,10 @@ class MockTorrent: } -def create_mock_session_response(torrents): +def create_mock_session_response(torrents, status_code=200): """Create a mock response for _session.get() calls.""" mock_response = MagicMock() + mock_response.status_code = status_code mock_response.json.return_value = [t.to_dict() if isinstance(t, MockTorrent) else t for t in torrents] mock_response.raise_for_status = MagicMock() return mock_response @@ -190,7 +191,7 @@ class TestQBittorrentClientGetStatus: mock_torrent = MockTorrent(progress=0.5, state="downloading", dlspeed=1024000, eta=3600) mock_client_instance = MagicMock() # Mock the session.get for _get_torrents_info - mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent]) + mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200) mock_client_class = MagicMock(return_value=mock_client_instance) with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): @@ -227,7 +228,7 @@ class TestQBittorrentClientGetStatus: ) mock_client_instance = MagicMock() # Mock the session.get for _get_torrents_info - mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent]) + mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200) mock_client_class = MagicMock(return_value=mock_client_instance) with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): @@ -242,6 +243,68 @@ class TestQBittorrentClientGetStatus: assert status.complete is True assert status.file_path == "/downloads/completed.epub" + def test_get_status_complete_derives_when_content_path_equals_save_path(self, monkeypatch): + """Keep get_status() and get_download_path() consistent.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + # content_path == save_path is treated as a path error + mock_torrent = MockTorrent( + hash_val="abc123", + progress=1.0, + state="uploading", + content_path="/downloads", + name="Some Torrent", + ) + # Ensure the torrent info payload contains save_path too + info_payload = mock_torrent.to_dict() | {"save_path": "/downloads"} + + def response(kind: str): + r = MagicMock() + r.status_code = 200 + r.raise_for_status = MagicMock() + if kind == "info": + r.json.return_value = [info_payload] + elif kind == "properties": + r.json.return_value = {"save_path": "/downloads"} + elif kind == "files": + r.json.return_value = [{"name": "Some Torrent/book.epub"}] + else: + raise AssertionError("unknown") + return r + + mock_client_instance = MagicMock() + + def get_side_effect(url, params=None, timeout=None): + if url.endswith("/api/v2/torrents/info"): + return response("info") + if url.endswith("/api/v2/torrents/properties"): + return response("properties") + if url.endswith("/api/v2/torrents/files"): + return response("files") + raise AssertionError(f"unexpected url: {url}") + + mock_client_instance._session.get.side_effect = get_side_effect + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + status = client.get_status("abc123") + + assert status.complete is True + assert status.file_path == "/downloads/Some Torrent" def test_get_status_not_found(self, monkeypatch): """Test status for non-existent torrent.""" config_values = { @@ -256,8 +319,12 @@ class TestQBittorrentClientGetStatus: ) mock_client_instance = MagicMock() - # Mock the session.get for _get_torrents_info - empty list - mock_client_instance._session.get.return_value = create_mock_session_response([]) + # hashes query empty -> category list empty -> full list empty + mock_client_instance._session.get.side_effect = [ + create_mock_session_response([], status_code=200), + create_mock_session_response([], status_code=200), + create_mock_session_response([], status_code=200), + ] mock_client_class = MagicMock(return_value=mock_client_instance) with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): @@ -269,6 +336,7 @@ class TestQBittorrentClientGetStatus: status = client.get_status("nonexistent") assert status.state_value == "error" + assert status.message is not None assert "not found" in status.message.lower() def test_get_status_stalled(self, monkeypatch): @@ -287,7 +355,7 @@ class TestQBittorrentClientGetStatus: mock_torrent = MockTorrent(progress=0.3, state="stalledDL") mock_client_instance = MagicMock() # Mock the session.get for _get_torrents_info - mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent]) + mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200) mock_client_class = MagicMock(return_value=mock_client_instance) with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): @@ -299,6 +367,7 @@ class TestQBittorrentClientGetStatus: status = client.get_status("abc123") assert status.state_value == "downloading" + assert status.message is not None assert "stalled" in status.message.lower() def test_get_status_paused(self, monkeypatch): @@ -317,7 +386,7 @@ class TestQBittorrentClientGetStatus: mock_torrent = MockTorrent(progress=0.5, state="pausedDL") mock_client_instance = MagicMock() # Mock the session.get for _get_torrents_info - mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent]) + mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200) mock_client_class = MagicMock(return_value=mock_client_instance) with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): @@ -346,7 +415,7 @@ class TestQBittorrentClientGetStatus: mock_torrent = MockTorrent(progress=0.1, state="error") mock_client_instance = MagicMock() # Mock the session.get for _get_torrents_info - mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent]) + mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200) mock_client_class = MagicMock(return_value=mock_client_instance) with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): @@ -380,6 +449,8 @@ class TestQBittorrentClientAddDownload: mock_client_instance = MagicMock() mock_client_instance.torrents_add.return_value = "Ok." mock_client_instance.torrents_info.return_value = [mock_torrent] + # Used by the properties check + mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200) mock_client_class = MagicMock(return_value=mock_client_instance) with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): @@ -392,6 +463,7 @@ class TestQBittorrentClientAddDownload: result = client.add_download(magnet, "Test Download") assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" + assert mock_client_instance._session.get.call_count >= 1 def test_add_download_creates_category(self, monkeypatch): """Test that add_download creates category if needed.""" @@ -399,7 +471,7 @@ class TestQBittorrentClientAddDownload: "QBITTORRENT_URL": "http://localhost:8080", "QBITTORRENT_USERNAME": "admin", "QBITTORRENT_PASSWORD": "password", - "QBITTORRENT_CATEGORY": "cwabd", + "QBITTORRENT_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get", @@ -412,6 +484,8 @@ class TestQBittorrentClientAddDownload: mock_client_instance = MagicMock() mock_client_instance.torrents_add.return_value = "Ok." mock_client_instance.torrents_info.return_value = [mock_torrent] + # Used by the properties check + mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200) mock_client_class = MagicMock(return_value=mock_client_instance) with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): @@ -423,7 +497,7 @@ class TestQBittorrentClientAddDownload: magnet = f"magnet:?xt=urn:btih:{valid_hash}&dn=test" client.add_download(magnet, "Test") - mock_client_instance.torrents_create_category.assert_called_once_with(name="cwabd") + mock_client_instance.torrents_create_category.assert_called_once_with(name="books") class TestQBittorrentClientRemove: @@ -486,6 +560,157 @@ class TestQBittorrentClientRemove: assert result is False +class TestQBittorrentClientGetDownloadPath: + """Tests for QBittorrentClient.get_download_path().""" + + def test_get_download_path_prefers_content_path(self, monkeypatch): + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_torrent = MockTorrent( + hash_val="abc123", + content_path="/downloads/some/book.epub", + ) + mock_client_instance = MagicMock() + mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200) + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + path = client.get_download_path("abc123") + + assert path == "/downloads/some/book.epub" + + def test_get_download_path_does_not_accept_content_path_equal_save_path(self, monkeypatch): + """content_path == save_path indicates a path error.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_torrent = MockTorrent( + hash_val="abc123", + content_path="/downloads", + ) + # emulate qbit reporting save_path too + setattr(mock_torrent, "save_path", "/downloads") + + def response(kind: str): + r = MagicMock() + r.status_code = 200 + r.raise_for_status = MagicMock() + if kind == "info": + r.json.return_value = [mock_torrent.to_dict() | {"save_path": "/downloads"}] + elif kind == "properties": + r.json.return_value = {"save_path": "/downloads"} + elif kind == "files": + r.json.return_value = [{"name": "Some Torrent/book.epub"}] + else: + raise AssertionError("unknown") + return r + + mock_client_instance = MagicMock() + + def get_side_effect(url, params=None, timeout=None): + if url.endswith("/api/v2/torrents/info"): + return response("info") + if url.endswith("/api/v2/torrents/properties"): + return response("properties") + if url.endswith("/api/v2/torrents/files"): + return response("files") + raise AssertionError(f"unexpected url: {url}") + + mock_client_instance._session.get.side_effect = get_side_effect + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + path = client.get_download_path("abc123") + + assert path == "/downloads/Some Torrent" + + def test_get_download_path_derives_from_files_when_missing_content_path(self, monkeypatch): + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + # Simulate emulator: no content_path, but we can derive from properties+files + mock_torrent = MockTorrent( + hash_val="abc123", + content_path="", + name="Some Torrent", + ) + + def json_for(response_kind: str): + if response_kind == "info": + return [mock_torrent.to_dict()] + if response_kind == "properties": + return {"save_path": "/downloads"} + if response_kind == "files": + return [{"name": "Some Torrent/book.epub"}] + raise AssertionError("unknown") + + def response(kind: str): + r = MagicMock() + r.status_code = 200 + r.raise_for_status = MagicMock() + r.json.return_value = json_for(kind) + return r + + mock_client_instance = MagicMock() + + def get_side_effect(url, params=None, timeout=None): + if url.endswith("/api/v2/torrents/info"): + return response("info") + if url.endswith("/api/v2/torrents/properties"): + return response("properties") + if url.endswith("/api/v2/torrents/files"): + return response("files") + raise AssertionError(f"unexpected url: {url}") + + mock_client_instance._session.get.side_effect = get_side_effect + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + path = client.get_download_path("abc123") + + assert path == "/downloads/Some Torrent" + + class TestQBittorrentClientFindExisting: """Tests for QBittorrentClient.find_existing().""" @@ -509,7 +734,7 @@ class TestQBittorrentClientFindExisting: ) mock_client_instance = MagicMock() # Mock the session.get for _get_torrents_info - mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent]) + mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200) mock_client_class = MagicMock(return_value=mock_client_instance) with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): @@ -540,8 +765,12 @@ class TestQBittorrentClientFindExisting: ) mock_client_instance = MagicMock() - # Mock the session.get for _get_torrents_info - empty list - mock_client_instance._session.get.return_value = create_mock_session_response([]) + # First call: hashes query returns empty. Second call (category listing) also empty. + mock_client_instance._session.get.side_effect = [ + create_mock_session_response([], status_code=200), + create_mock_session_response([], status_code=200), + create_mock_session_response([], status_code=200), + ] mock_client_class = MagicMock(return_value=mock_client_instance) with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): diff --git a/tests/prowlarr/test_remote_path_mappings.py b/tests/prowlarr/test_remote_path_mappings.py new file mode 100644 index 0000000..1d5b1a2 --- /dev/null +++ b/tests/prowlarr/test_remote_path_mappings.py @@ -0,0 +1,91 @@ +"""Tests for remote path mappings. + +This focuses on integration of mapping logic into the Prowlarr handler. +""" + +import tempfile +from pathlib import Path +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.release_sources.prowlarr.handler import ProwlarrHandler + + +class ProgressRecorder: + def __init__(self): + self.progress_values = [] + self.status_updates = [] + + def progress_callback(self, progress: float): + self.progress_values.append(progress) + + def status_callback(self, status: str, message: str | None): + self.status_updates.append((status, message)) + + +def test_remaps_completed_path_when_remote_path_missing(): + with tempfile.TemporaryDirectory() as tmp_dir: + local_file = Path(tmp_dir) / "local" / "book.epub" + local_file.parent.mkdir(parents=True) + local_file.write_text("test content") + + remote_path = "/remote/downloads/book.epub" + + mock_client = MagicMock() + mock_client.name = "qbittorrent" + mock_client.find_existing.return_value = None + mock_client.add_download.return_value = "download_id" + mock_client.get_status.return_value = DownloadStatus( + progress=100, + state=DownloadState.COMPLETE, + message="Complete", + complete=True, + file_path=remote_path, + ) + mock_client.get_download_path.return_value = remote_path + + def config_get(key: str, default=""): + if key == "PROWLARR_REMOTE_PATH_MAPPINGS": + return [ + { + "host": "qbittorrent", + "remotePath": "/remote/downloads", + "localPath": str(local_file.parent), + } + ] + return default + + with patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), patch( + "shelfmark.release_sources.prowlarr.handler.config.get", + side_effect=config_get, + ), patch( + "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ): + handler = ProwlarrHandler() + task = DownloadTask(task_id="poll-mapping-test", source="prowlarr", title="Test Book") + 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 == str(local_file) + assert task.original_download_path == str(local_file) diff --git a/tests/prowlarr/test_rtorrent_client.py b/tests/prowlarr/test_rtorrent_client.py index bd3711b..c02f129 100644 --- a/tests/prowlarr/test_rtorrent_client.py +++ b/tests/prowlarr/test_rtorrent_client.py @@ -355,6 +355,7 @@ class TestRTorrentClientGetStatus: 1024000, 0, "cwabd", + 0, ] ] @@ -401,6 +402,7 @@ class TestRTorrentClientGetStatus: 0, 2048000, "cwabd", + 1, ] ] mock_rpc.d.get_base_path.return_value = "/downloads/test-torrent" @@ -483,6 +485,7 @@ class TestRTorrentClientGetStatus: 1048576, 0, "cwabd", + 0, ] ] diff --git a/tests/prowlarr/test_sabnzbd_client.py b/tests/prowlarr/test_sabnzbd_client.py index 72ed0c4..2a6a696 100644 --- a/tests/prowlarr/test_sabnzbd_client.py +++ b/tests/prowlarr/test_sabnzbd_client.py @@ -95,7 +95,7 @@ class TestSABnzbdClientTestConnection: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -126,7 +126,7 @@ class TestSABnzbdClientTestConnection: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "wrong", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -156,7 +156,7 @@ class TestSABnzbdClientGetStatus: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -205,7 +205,7 @@ class TestSABnzbdClientGetStatus: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -222,7 +222,8 @@ class TestSABnzbdClientGetStatus: { "nzo_id": "SABnzbd_nzo_abc123", "status": "Completed", - "storage": "/downloads/complete/book", + "storage": "/downloads/complete/book/Sorted/Subfolder", + "name": "book", } ] } @@ -245,7 +246,7 @@ class TestSABnzbdClientGetStatus: assert status.progress == 100.0 assert status.state_value == "complete" assert status.complete is True - assert status.file_path == "/downloads/complete/book" + assert status.file_path == "/downloads/complete/book" # resolved to job root def test_get_status_complete_empty_storage(self, monkeypatch): """Test status for completed NZB with empty storage path. @@ -256,7 +257,7 @@ class TestSABnzbdClientGetStatus: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -303,7 +304,7 @@ class TestSABnzbdClientGetStatus: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -348,7 +349,7 @@ class TestSABnzbdClientGetStatus: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -383,7 +384,7 @@ class TestSABnzbdClientGetStatus: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -427,7 +428,7 @@ class TestSABnzbdClientGetStatus: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -475,7 +476,7 @@ class TestSABnzbdClientAddDownload: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -509,7 +510,7 @@ class TestSABnzbdClientAddDownload: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -545,7 +546,7 @@ class TestSABnzbdClientRemove: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -577,7 +578,7 @@ class TestSABnzbdClientRemove: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -620,7 +621,7 @@ class TestSABnzbdClientFindExisting: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -667,7 +668,7 @@ class TestSABnzbdClientFindExisting: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get", @@ -714,7 +715,7 @@ class TestSABnzbdClientFindExisting: config_values = { "SABNZBD_URL": "http://localhost:8080", "SABNZBD_API_KEY": "abc123", - "SABNZBD_CATEGORY": "cwabd", + "SABNZBD_CATEGORY": "books", } monkeypatch.setattr( "shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",