Remote path mappings, Client handling improvements (#481)

This commit is contained in:
Alex
2026-01-17 14:52:06 +00:00
committed by GitHub
parent fd74021594
commit 5a6db5f8a8
26 changed files with 1501 additions and 155 deletions

1
.gitignore vendored
View File

@@ -229,5 +229,6 @@ pyrightconfig.json
/downloaded_files
/.local/
*.local.*
AGENTS.md
.claude/
.playwright-mcp/

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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", "")),

View File

@@ -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:

View File

@@ -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=<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=<hash>` for `save_path`
- `/api/v2/torrents/files?hash=<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:

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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,

View File

@@ -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 <ActionButton field={field as ActionButtonConfig} onAction={onAction} disabled={isDisabled} />;
case 'TableField':
return (
<TableField
field={field as TableFieldConfig}
value={(value as Record<string, unknown>[]) ?? []}
onChange={onChange}
disabled={isDisabled}
/>
);
case 'HeadingField':
return <HeadingField field={field as HeadingFieldConfig} />;
default:

View File

@@ -0,0 +1,208 @@
import { useMemo } from 'react';
import { TableFieldConfig, TableFieldColumn } from '../../../types/settings';
import { DropdownList } from '../../DropdownList';
interface TableFieldProps {
field: TableFieldConfig;
value: Record<string, unknown>[];
onChange: (value: Record<string, unknown>[]) => 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<string, unknown>[], columns: TableFieldColumn[]): Record<string, unknown>[] {
return (rows ?? []).map((row) => {
const normalized: Record<string, unknown> = { ...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<string, unknown> = {};
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 (
<div className="space-y-3">
{field.emptyMessage && <p className="text-sm opacity-70">{field.emptyMessage}</p>}
<button
type="button"
onClick={addRow}
disabled={isDisabled}
className="px-3 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
>
{field.addLabel || 'Add'}
</button>
</div>
);
}
// 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 (
<div className="space-y-3 min-w-0">
<div className={`hidden sm:grid ${gridTemplate} gap-2 px-1 text-xs font-medium opacity-70`}>
{columns.map((col) => (
<div key={col.key} className="truncate">
{col.label}
</div>
))}
<div />
</div>
<div className="space-y-3 min-w-0">
{rows.map((row, rowIndex) => (
<div
key={rowIndex}
className={`grid grid-cols-1 ${gridTemplate} gap-3 items-start min-w-0`}
style={{ overflow: 'visible' }}
>
{columns.map((col) => {
const cellValue = row[col.key];
const mobileLabel = <div className="sm:hidden text-xs font-medium opacity-70">{col.label}</div>;
if (col.type === 'checkbox') {
return (
<div key={col.key} className="flex flex-col gap-1 min-w-0">
{mobileLabel}
<div className="pt-2">
<input
type="checkbox"
checked={Boolean(cellValue)}
onChange={(e) => 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"
/>
</div>
</div>
);
}
if (col.type === 'select') {
const options = (col.options ?? []).map((opt) => ({
value: String(opt.value),
label: opt.label,
description: opt.description,
}));
return (
<div key={col.key} className="flex flex-col gap-1 min-w-0">
{mobileLabel}
{isDisabled ? (
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
{options.find((o) => o.value === String(cellValue ?? ''))?.label || 'Select...'}
</div>
) : (
<DropdownList
options={options}
value={String(cellValue ?? '')}
onChange={(val) => updateCell(rowIndex, col.key, Array.isArray(val) ? val[0] : val)}
placeholder={col.placeholder || 'Select...'}
widthClassName="w-full"
/>
)}
</div>
);
}
// text/path
return (
<div key={col.key} className="flex flex-col gap-1 min-w-0">
{mobileLabel}
<input
type="text"
value={String(cellValue ?? '')}
onChange={(e) => 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"
/>
</div>
);
})}
<div className="flex items-center justify-center">
<button
type="button"
onClick={() => removeRow(rowIndex)}
disabled={isDisabled}
className="p-2 rounded hover:bg-[var(--hover-surface)]
disabled:opacity-60 disabled:cursor-not-allowed"
aria-label="Remove row"
>
<svg
className="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="col-span-full border-t border-[var(--border-muted)] opacity-60" />
</div>
))}
</div>
<button
type="button"
onClick={addRow}
disabled={isDisabled}
className="px-3 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
>
{field.addLabel || 'Add'}
</button>
</div>
);
};

View File

@@ -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';

View File

@@ -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 ?? '';
}

View File

@@ -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<string, unknown>[];
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;

View File

@@ -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."""

View File

@@ -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()) == []

View File

@@ -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:

View File

@@ -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:

View File

@@ -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",

View File

@@ -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)}):

View File

@@ -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)

View File

@@ -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,
]
]

View File

@@ -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",