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:
Tilian B
2026-07-30 23:38:53 -04:00
committed by GitHub
parent 340853477f
commit ff770940ca
13 changed files with 233 additions and 9 deletions
+11
View File
@@ -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()."""
+55
View File
@@ -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")
+14
View File
@@ -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()."""
+10
View File
@@ -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()."""