mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-07-31 23:06:19 -04:00
Add torrent post-import category action (#1148)
## Summary - add a **Change Category** torrent completion action and conditionally show its post-import category/label setting - update kept torrents only after a successful library import - support qBittorrent categories, Transmission labels, Deluge's Label plugin, and rTorrent's `custom1` label - preserve existing Keep/Remove behavior and document the new environment setting ## Behavior Category changes happen from `post_process_cleanup`, after output transfer and post-processing complete. This keeps the existing hardlink flow unchanged. An empty post-import category is a no-op, and client API failures are logged without turning a successful library import into a failure. ## Validation - `pytest -n 0 tests/prowlarr/test_qbittorrent_client.py tests/prowlarr/test_transmission_client.py tests/prowlarr/test_deluge_client.py tests/prowlarr/test_rtorrent_client.py tests/prowlarr/test_handler.py tests/config/test_generate_env_docs.py` — 161 passed - `ruff check` on all changed Python files — passed - `basedpyright` on the changed client implementation files — passed - full `basedpyright shelfmark/download/clients` currently reports two pre-existing errors in the new debrid connection-test code at `settings.py:546` and `settings.py:563`, outside this PR's diff - multi-architecture Docker images built successfully for `linux/amd64` and `linux/arm64`
This commit is contained in:
@@ -1534,7 +1534,8 @@ How long to keep cached search results before they expire.
|
||||
| `RTORRENT_LABEL` | Label to assign to ebook downloads in rTorrent | string | `cwabd` |
|
||||
| `RTORRENT_AUDIOBOOK_LABEL` | Label to assign to audiobook downloads in rTorrent (falls back to Book Label if not set) | string | _none_ |
|
||||
| `RTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
|
||||
| `PROWLARR_TORRENT_ACTION` | Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding | string (choice) | `keep` |
|
||||
| `PROWLARR_TORRENT_ACTION` | Choose whether to keep, remove, or move the torrent to another category or label after import | string (choice) | `keep` |
|
||||
| `PROWLARR_TORRENT_POST_IMPORT_CATEGORY` | Category or label to assign after a successful import | string | _empty string_ |
|
||||
| `PROWLARR_USENET_CLIENT` | Choose which usenet client to use | string (choice) | _empty string_ |
|
||||
| `NZBGET_URL` | URL of your NZBGet instance | string | _none_ |
|
||||
| `NZBGET_USERNAME` | NZBGet control username | string | `nzbget` |
|
||||
@@ -1798,11 +1799,20 @@ Server-side directory where torrents are downloaded (optional, uses rTorrent def
|
||||
|
||||
**Torrent Completion Action**
|
||||
|
||||
Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding
|
||||
Choose whether to keep, remove, or move the torrent to another category or label after import
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `keep`
|
||||
- **Options:** `keep` (Keep), `remove` (Remove)
|
||||
- **Options:** `keep` (Keep), `remove` (Remove), `change_category` (Change Category)
|
||||
|
||||
#### `PROWLARR_TORRENT_POST_IMPORT_CATEGORY`
|
||||
|
||||
**Post-Import Category**
|
||||
|
||||
Category or label to assign after a successful import
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _empty string_
|
||||
|
||||
#### `PROWLARR_USENET_CLIENT`
|
||||
|
||||
|
||||
@@ -325,6 +325,19 @@ class DownloadClient(ABC):
|
||||
|
||||
"""
|
||||
|
||||
def set_category(self, download_id: str, category: str) -> bool:
|
||||
"""Update a download's category or label when supported by the client.
|
||||
|
||||
Args:
|
||||
download_id: The client-specific download ID.
|
||||
category: Category or label to assign.
|
||||
|
||||
Returns:
|
||||
True if the category was updated, otherwise False.
|
||||
|
||||
"""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where files were downloaded.
|
||||
|
||||
@@ -284,17 +284,45 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
)
|
||||
|
||||
elif protocol == "torrent":
|
||||
if config.get("PROWLARR_TORRENT_ACTION", "keep") != "remove":
|
||||
torrent_action = config.get("PROWLARR_TORRENT_ACTION", "keep")
|
||||
if torrent_action == "remove":
|
||||
try:
|
||||
client.remove(download_id, delete_files=False)
|
||||
except _CLIENT_CLEANUP_ERRORS as e:
|
||||
logger.warning(
|
||||
"Failed to remove torrent %s from %s: %s",
|
||||
download_id,
|
||||
getattr(client, "name", "client"),
|
||||
e,
|
||||
)
|
||||
return
|
||||
|
||||
if torrent_action != "change_category":
|
||||
return
|
||||
|
||||
post_import_category = normalize_optional_text(
|
||||
config.get("PROWLARR_TORRENT_POST_IMPORT_CATEGORY", "")
|
||||
)
|
||||
if post_import_category is None:
|
||||
return
|
||||
|
||||
try:
|
||||
client.remove(download_id, delete_files=False)
|
||||
category_updated = client.set_category(download_id, post_import_category)
|
||||
except _CLIENT_CLEANUP_ERRORS as e:
|
||||
logger.warning(
|
||||
"Failed to remove torrent %s from %s: %s",
|
||||
"Failed to set post-import category for torrent %s in %s: %s",
|
||||
download_id,
|
||||
getattr(client, "name", "client"),
|
||||
e,
|
||||
)
|
||||
return
|
||||
|
||||
if not category_updated:
|
||||
logger.warning(
|
||||
"Failed to set post-import category for torrent %s in %s",
|
||||
download_id,
|
||||
getattr(client, "name", "client"),
|
||||
)
|
||||
|
||||
def _remove_usenet_download(
|
||||
self,
|
||||
|
||||
@@ -227,10 +227,10 @@ class DelugeClient(DownloadClient):
|
||||
|
||||
return self._rpc_call("daemon.info")
|
||||
|
||||
def _try_set_label(self, torrent_id: str, label: str) -> None:
|
||||
def _try_set_label(self, torrent_id: str, label: str) -> bool:
|
||||
"""Best-effort label assignment (requires Deluge Label plugin)."""
|
||||
if not label:
|
||||
return
|
||||
return False
|
||||
|
||||
try:
|
||||
# label.add will error if the plugin is unavailable or the label exists.
|
||||
@@ -240,6 +240,9 @@ class DelugeClient(DownloadClient):
|
||||
self._rpc_call("label.set_torrent", torrent_id, label)
|
||||
except _DELUGE_CLIENT_ERRORS as e:
|
||||
logger.debug("Could not set Deluge label '%s' for %s: %s", label, torrent_id, e)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
@@ -422,6 +425,15 @@ class DelugeClient(DownloadClient):
|
||||
else:
|
||||
return False
|
||||
|
||||
def set_category(self, download_id: str, category: str) -> bool:
|
||||
"""Assign a label to a torrent using Deluge's Label plugin."""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
return self._try_set_label(download_id, category)
|
||||
except _DELUGE_CLIENT_ERRORS as e:
|
||||
self._log_error("set_category", e)
|
||||
return False
|
||||
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Return the resolved download path for a Deluge torrent."""
|
||||
try:
|
||||
|
||||
@@ -673,6 +673,26 @@ class QBittorrentClient(DownloadClient):
|
||||
else:
|
||||
return True
|
||||
|
||||
def set_category(self, download_id: str, category: str) -> bool:
|
||||
"""Assign a category to a torrent in qBittorrent."""
|
||||
try:
|
||||
try:
|
||||
self._client.torrents_create_category(name=category)
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
if "Conflict" not in type(e).__name__ and "409" not in str(e):
|
||||
logger.debug("Could not create category '%s': %s", category, e)
|
||||
|
||||
self._client.torrents_set_category(
|
||||
torrent_hashes=download_id,
|
||||
category=category,
|
||||
)
|
||||
logger.info("Set qBittorrent category for %s to '%s'", download_id, category)
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
self._log_error("set_category", e)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where torrent files are located.
|
||||
|
||||
|
||||
@@ -47,7 +47,13 @@ class _RTorrentLoadProtocol(Protocol):
|
||||
def start(self, target: str, url: str, commands: str) -> object: ...
|
||||
|
||||
|
||||
class _RTorrentCustom1Protocol(Protocol):
|
||||
def set(self, download_id: str, value: str) -> object: ...
|
||||
|
||||
|
||||
class _RTorrentDownloadProtocol(Protocol):
|
||||
custom1: _RTorrentCustom1Protocol
|
||||
|
||||
def multicall2(self, *args: object) -> list[list[Any]]: ...
|
||||
|
||||
def delete_tied(self, download_id: str) -> object: ...
|
||||
@@ -351,6 +357,18 @@ class RTorrentClient(DownloadClient):
|
||||
else:
|
||||
return True
|
||||
|
||||
def set_category(self, download_id: str, category: str) -> bool:
|
||||
"""Assign a label to a torrent using rTorrent's custom1 field."""
|
||||
try:
|
||||
self._rpc.d.custom1.set(download_id, category)
|
||||
logger.info("Set rTorrent label for %s to '%s'", download_id, category)
|
||||
except _RTORRENT_CLIENT_ERRORS as e:
|
||||
error_type = type(e).__name__
|
||||
logger.exception("rTorrent set_category failed (%s)", error_type)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where torrent files are located.
|
||||
|
||||
|
||||
@@ -854,14 +854,23 @@ def prowlarr_clients_settings() -> list[SettingsField]:
|
||||
SelectField(
|
||||
key="PROWLARR_TORRENT_ACTION",
|
||||
label="Torrent Completion Action",
|
||||
description="Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding",
|
||||
description="Choose whether to keep, remove, or move the torrent to another category or label after import",
|
||||
options=[
|
||||
{"value": "keep", "label": "Keep"},
|
||||
{"value": "remove", "label": "Remove"},
|
||||
{"value": "change_category", "label": "Change Category"},
|
||||
],
|
||||
default="keep",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
|
||||
),
|
||||
TextField(
|
||||
key="PROWLARR_TORRENT_POST_IMPORT_CATEGORY",
|
||||
label="Post-Import Category",
|
||||
description="Category or label to assign after a successful import",
|
||||
placeholder="imported",
|
||||
default="",
|
||||
show_when={"field": "PROWLARR_TORRENT_ACTION", "value": "change_category"},
|
||||
),
|
||||
# --- Usenet Client Selection ---
|
||||
HeadingField(
|
||||
key="usenet_heading",
|
||||
|
||||
@@ -390,6 +390,17 @@ class TransmissionClient(DownloadClient):
|
||||
else:
|
||||
return True
|
||||
|
||||
def set_category(self, download_id: str, category: str) -> bool:
|
||||
"""Replace a torrent's labels with the post-import label."""
|
||||
try:
|
||||
self._client.change_torrent(ids=download_id, labels=[category])
|
||||
logger.info("Set Transmission label for %s to '%s'", download_id, category)
|
||||
except _TRANSMISSION_CLIENT_ERRORS as e:
|
||||
self._log_error("set_category", e)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where torrent files are located.
|
||||
|
||||
|
||||
@@ -15,6 +15,17 @@ def make_config_getter(values):
|
||||
return getter
|
||||
|
||||
|
||||
def test_set_category_uses_label_plugin(monkeypatch):
|
||||
from shelfmark.download.clients.deluge import DelugeClient
|
||||
|
||||
client = DelugeClient.__new__(DelugeClient)
|
||||
monkeypatch.setattr(client, "_ensure_connected", MagicMock())
|
||||
monkeypatch.setattr(client, "_try_set_label", MagicMock(return_value=True))
|
||||
|
||||
assert client.set_category("abc123", "imported") is True
|
||||
client._try_set_label.assert_called_once_with("abc123", "imported")
|
||||
|
||||
|
||||
class TestDelugeClientAddDownload:
|
||||
"""Tests for DelugeClient.add_download()."""
|
||||
|
||||
|
||||
@@ -1456,6 +1456,61 @@ class TestProwlarrHandlerFileStaging:
|
||||
|
||||
|
||||
class TestProwlarrHandlerPostProcessCleanup:
|
||||
def test_torrent_change_category_sets_post_import_category(self):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(task_id="torrent-category", source="prowlarr", title="Test")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.name = "qbittorrent"
|
||||
mock_client.set_category.return_value = True
|
||||
handler._cleanup_refs[task.task_id] = (mock_client, "abc123", "torrent")
|
||||
|
||||
config_values = {
|
||||
"PROWLARR_TORRENT_ACTION": "change_category",
|
||||
"PROWLARR_TORRENT_POST_IMPORT_CATEGORY": "imported",
|
||||
}
|
||||
with patch(
|
||||
"shelfmark.download.clients.base_handler.config.get",
|
||||
side_effect=lambda key, default="": config_values.get(key, default),
|
||||
):
|
||||
handler.post_process_cleanup(task, success=True)
|
||||
|
||||
mock_client.set_category.assert_called_once_with("abc123", "imported")
|
||||
mock_client.remove.assert_not_called()
|
||||
|
||||
def test_torrent_keep_does_not_change_category(self):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(task_id="torrent-no-category", source="prowlarr", title="Test")
|
||||
|
||||
mock_client = MagicMock()
|
||||
handler._cleanup_refs[task.task_id] = (mock_client, "abc123", "torrent")
|
||||
|
||||
with patch("shelfmark.download.clients.base_handler.config.get", return_value="keep"):
|
||||
handler.post_process_cleanup(task, success=True)
|
||||
|
||||
mock_client.set_category.assert_not_called()
|
||||
mock_client.remove.assert_not_called()
|
||||
|
||||
def test_torrent_change_category_ignores_empty_category(self):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(task_id="torrent-empty-category", source="prowlarr", title="Test")
|
||||
|
||||
mock_client = MagicMock()
|
||||
handler._cleanup_refs[task.task_id] = (mock_client, "abc123", "torrent")
|
||||
|
||||
config_values = {
|
||||
"PROWLARR_TORRENT_ACTION": "change_category",
|
||||
"PROWLARR_TORRENT_POST_IMPORT_CATEGORY": "",
|
||||
}
|
||||
with patch(
|
||||
"shelfmark.download.clients.base_handler.config.get",
|
||||
side_effect=lambda key, default="": config_values.get(key, default),
|
||||
):
|
||||
handler.post_process_cleanup(task, success=True)
|
||||
|
||||
mock_client.set_category.assert_not_called()
|
||||
mock_client.remove.assert_not_called()
|
||||
|
||||
def test_usenet_move_triggers_client_cleanup(self):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(task_id="cleanup-test", source="prowlarr", title="Test")
|
||||
|
||||
@@ -59,6 +59,20 @@ def create_mock_session_response(torrents, status_code=200):
|
||||
return mock_response
|
||||
|
||||
|
||||
def test_set_category_creates_and_assigns_category():
|
||||
from shelfmark.download.clients.qbittorrent import QBittorrentClient
|
||||
|
||||
client = QBittorrentClient.__new__(QBittorrentClient)
|
||||
client._client = MagicMock()
|
||||
|
||||
assert client.set_category("abc123", "imported") is True
|
||||
client._client.torrents_create_category.assert_called_once_with(name="imported")
|
||||
client._client.torrents_set_category.assert_called_once_with(
|
||||
torrent_hashes="abc123",
|
||||
category="imported",
|
||||
)
|
||||
|
||||
|
||||
class TestQBittorrentClientIsConfigured:
|
||||
"""Tests for QBittorrentClient.is_configured()."""
|
||||
|
||||
|
||||
@@ -27,6 +27,16 @@ def create_mock_xmlrpc_module():
|
||||
return mock_module
|
||||
|
||||
|
||||
def test_set_category_updates_custom1_label():
|
||||
from shelfmark.download.clients.rtorrent import RTorrentClient
|
||||
|
||||
client = RTorrentClient.__new__(RTorrentClient)
|
||||
client._rpc = MagicMock()
|
||||
|
||||
assert client.set_category("abc123", "imported") is True
|
||||
client._rpc.d.custom1.set.assert_called_once_with("abc123", "imported")
|
||||
|
||||
|
||||
class TestRTorrentClientIsConfigured:
|
||||
"""Tests for RTorrentClient.is_configured()."""
|
||||
|
||||
|
||||
@@ -68,6 +68,19 @@ def create_mock_transmission_rpc_module():
|
||||
return mock_module
|
||||
|
||||
|
||||
def test_set_category_replaces_labels():
|
||||
from shelfmark.download.clients.transmission import TransmissionClient
|
||||
|
||||
client = TransmissionClient.__new__(TransmissionClient)
|
||||
client._client = MagicMock()
|
||||
|
||||
assert client.set_category("abc123", "imported") is True
|
||||
client._client.change_torrent.assert_called_once_with(
|
||||
ids="abc123",
|
||||
labels=["imported"],
|
||||
)
|
||||
|
||||
|
||||
class TestTransmissionClientIsConfigured:
|
||||
"""Tests for TransmissionClient.is_configured()."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user