mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-02-20 07:46:18 -05:00
Remote path mappings, Client handling improvements (#481)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -229,5 +229,6 @@ pyrightconfig.json
|
||||
/downloaded_files
|
||||
/.local/
|
||||
*.local.*
|
||||
AGENTS.md
|
||||
.claude/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -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)
|
||||
|
||||
102
shelfmark/core/path_mappings.py
Normal file
102
shelfmark/core/path_mappings.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", "")),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
208
src/frontend/src/components/settings/fields/TableField.tsx
Normal file
208
src/frontend/src/components/settings/fields/TableField.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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 ?? '';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()) == []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}):
|
||||
|
||||
91
tests/prowlarr/test_remote_path_mappings.py
Normal file
91
tests/prowlarr/test_remote_path_mappings.py
Normal 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)
|
||||
@@ -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,
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user