Remote path mappings, Client handling improvements (#481)

This commit is contained in:
Alex
2026-01-17 14:52:06 +00:00
committed by GitHub
parent fd74021594
commit 5a6db5f8a8
26 changed files with 1501 additions and 155 deletions
+54 -1
View File
@@ -333,6 +333,7 @@ class TestProcessDirectory:
)
assert final_paths == []
assert error is not None
assert "format not supported" in error
assert ".pdf" in error
@@ -446,6 +447,7 @@ class TestProcessDirectory:
)
assert final_paths == []
assert error is not None
assert "Move failed" in error
# Directory should be cleaned up
assert not directory.exists()
@@ -517,6 +519,7 @@ class TestPostProcessDownload:
status_callback=status_cb,
)
assert result is not None
result_path = Path(result)
assert "The Way of Kings" in result_path.name
@@ -588,6 +591,7 @@ class TestPostProcessDownload:
status_callback=status_cb,
)
assert result is not None
result_path = Path(result)
# Should go to ingest, not library
assert result_path.parent == temp_dirs["ingest"]
@@ -660,6 +664,7 @@ class TestPostProcessDownload:
status_callback=status_cb,
)
assert result is not None
result_path = Path(result)
assert result_path.parent == audiobook_ingest
@@ -704,7 +709,55 @@ class TestCustomScriptExecution:
assert result is not None
mock_run.assert_called_once()
call_args = mock_run.call_args
assert call_args[0][0] == ["/path/to/script.sh", str(temp_file)]
result_path = Path(result)
assert call_args[0][0] == ["/path/to/script.sh", str(result_path)]
def test_runs_custom_script_for_directory_download_once(self, temp_dirs):
"""Runs custom script once after transferring a directory download."""
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
download_dir = temp_dirs["staging"] / "release"
download_dir.mkdir()
(download_dir / "01.mp3").write_bytes(b"a")
(download_dir / "02.mp3").write_bytes(b"b")
task = DownloadTask(
task_id="test-dir",
source="direct_download",
title="My Book",
author="An Author",
format="mp3",
search_mode=SearchMode.DIRECT,
content_type="audiobook",
)
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \
patch('subprocess.run') as mock_run:
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = "/path/to/script.sh"
_sync_core_config(mock_config, mock_config)
mock_config.get = _mock_destination_config(temp_dirs["ingest"], {"FILE_ORGANIZATION_AUDIOBOOK": "none"})
_sync_core_config(mock_config, mock_config)
mock_run.return_value = MagicMock(stdout="", returncode=0)
result = _post_process_download(
temp_file=download_dir,
task=task,
cancel_flag=cancel_flag,
status_callback=status_cb,
)
assert result is not None
assert mock_run.call_count == 1
script_args = mock_run.call_args[0][0]
assert script_args[0] == "/path/to/script.sh"
assert Path(script_args[1]) == temp_dirs["ingest"]
def test_script_not_found_error(self, temp_dirs, sample_direct_task):
"""Returns error when script not found."""
+2 -4
View File
@@ -803,13 +803,11 @@ def test_custom_script_external_source_stages_copy_and_preserves_source(tmp_path
# Original external file must be preserved.
assert original.exists()
# Script should have run against a staged copy inside TMP.
# Script should have run against the final imported file.
assert mock_run.call_count == 1
script_args = mock_run.call_args[0][0]
assert script_args[0] == "/path/to/script.sh"
staged_path = Path(script_args[1])
assert staging in staged_path.parents
assert staged_path != original
assert Path(script_args[1]) == result_path
# Staging directory should be cleaned.
assert list(staging.iterdir()) == []
+127
View File
@@ -105,6 +105,7 @@ class TestProwlarrHandlerDownloadErrors:
assert result is None
assert recorder.last_status == "error"
assert recorder.last_message is not None
assert "cache" in recorder.last_message.lower()
def test_download_fails_without_download_url(self):
@@ -135,6 +136,7 @@ class TestProwlarrHandlerDownloadErrors:
assert result is None
assert recorder.last_status == "error"
assert recorder.last_message is not None
assert "url" in recorder.last_message.lower()
def test_download_fails_unknown_protocol(self):
@@ -164,6 +166,7 @@ class TestProwlarrHandlerDownloadErrors:
assert result is None
assert recorder.last_status == "error"
assert recorder.last_message is not None
assert "protocol" in recorder.last_message.lower()
def test_download_fails_no_client_configured(self):
@@ -199,6 +202,7 @@ class TestProwlarrHandlerDownloadErrors:
assert result is None
assert recorder.last_status == "error"
assert recorder.last_message is not None
assert "client" in recorder.last_message.lower()
@@ -270,6 +274,129 @@ class TestProwlarrHandlerExistingDownload:
class TestProwlarrHandlerPolling:
"""Tests for download polling behavior."""
def test_retries_torrent_not_found_errors(self):
""""Torrent not found" should be treated as transient."""
with tempfile.TemporaryDirectory() as tmp_dir:
source_file = Path(tmp_dir) / "source" / "book.epub"
source_file.parent.mkdir(parents=True)
source_file.write_text("test content")
staging_dir = Path(tmp_dir) / "staging"
staging_dir.mkdir()
poll_count = [0]
def mock_get_status(download_id):
poll_count[0] += 1
if poll_count[0] <= 2:
return DownloadStatus(
progress=0,
state=DownloadState.ERROR,
message="Torrent not found in qBittorrent",
complete=False,
file_path=None,
)
return DownloadStatus(
progress=100,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=str(source_file),
)
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.return_value = "download_id"
mock_client.get_status.side_effect = mock_get_status
mock_client.get_download_path.return_value = str(source_file)
with patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
},
), patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
"shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
0.01,
):
handler = ProwlarrHandler()
task = DownloadTask(
task_id="poll-not-found-test",
source="prowlarr",
title="Test Book",
)
cancel_flag = Event()
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result is not None
assert poll_count[0] >= 3
assert "resolving" in recorder.statuses
def test_fails_fast_on_auth_errors(self):
"""Auth/API errors should not be retried as "not found"."""
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.return_value = "download_id"
mock_client.get_status.return_value = DownloadStatus(
progress=0,
state=DownloadState.ERROR,
message="qBittorrent authentication failed (HTTP 403)",
complete=False,
file_path=None,
)
with patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
},
), patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
0.01,
):
handler = ProwlarrHandler()
task = DownloadTask(
task_id="poll-auth-fail-test",
source="prowlarr",
title="Test Book",
)
cancel_flag = Event()
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result is None
assert recorder.last_status == "error"
assert "authentication failed" in (recorder.last_message or "").lower()
def test_polls_until_complete(self):
"""Test that handler polls until download is complete."""
with tempfile.TemporaryDirectory() as tmp_dir:
+3 -3
View File
@@ -311,7 +311,7 @@ class TestTransmissionIntegration:
# State should be a known value
valid_states = {"downloading", "complete", "error", "seeding", "paused", "queued", "fetching_metadata"}
assert status.state in valid_states
assert status.state.value in valid_states
# Complete should be boolean
assert isinstance(status.complete, bool)
@@ -396,7 +396,7 @@ class TestQBittorrentIntegration:
assert 0 <= status.progress <= 100
valid_states = {"downloading", "complete", "error", "seeding", "paused", "queued", "fetching_metadata", "stalled"}
assert status.state in valid_states
assert status.state.value in valid_states
assert isinstance(status.complete, bool)
finally:
@@ -480,7 +480,7 @@ class TestDelugeIntegration:
assert 0 <= status.progress <= 100
valid_states = {"downloading", "complete", "error", "seeding", "paused", "queued", "fetching_metadata", "checking"}
assert status.state in valid_states
assert status.state.value in valid_states
assert isinstance(status.complete, bool)
finally:
+1 -1
View File
@@ -595,7 +595,7 @@ class TestNZBGetClientRemove:
assert calls[-1][1][0] == "GroupDelete"
def test_remove_falls_back_to_history_delete(self, monkeypatch):
"""If HistoryFinalDelete is unsupported, fall back to HistoryDelete (Sonarr behavior)."""
"""If HistoryFinalDelete is unsupported, fall back to HistoryDelete."""
config_values = {
"NZBGET_URL": "http://localhost:6789",
"NZBGET_USERNAME": "nzbget",
+242 -13
View File
@@ -46,9 +46,10 @@ class MockTorrent:
}
def create_mock_session_response(torrents):
def create_mock_session_response(torrents, status_code=200):
"""Create a mock response for _session.get() calls."""
mock_response = MagicMock()
mock_response.status_code = status_code
mock_response.json.return_value = [t.to_dict() if isinstance(t, MockTorrent) else t for t in torrents]
mock_response.raise_for_status = MagicMock()
return mock_response
@@ -190,7 +191,7 @@ class TestQBittorrentClientGetStatus:
mock_torrent = MockTorrent(progress=0.5, state="downloading", dlspeed=1024000, eta=3600)
mock_client_instance = MagicMock()
# Mock the session.get for _get_torrents_info
mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent])
mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
@@ -227,7 +228,7 @@ class TestQBittorrentClientGetStatus:
)
mock_client_instance = MagicMock()
# Mock the session.get for _get_torrents_info
mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent])
mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
@@ -242,6 +243,68 @@ class TestQBittorrentClientGetStatus:
assert status.complete is True
assert status.file_path == "/downloads/completed.epub"
def test_get_status_complete_derives_when_content_path_equals_save_path(self, monkeypatch):
"""Keep get_status() and get_download_path() consistent."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "test",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
# content_path == save_path is treated as a path error
mock_torrent = MockTorrent(
hash_val="abc123",
progress=1.0,
state="uploading",
content_path="/downloads",
name="Some Torrent",
)
# Ensure the torrent info payload contains save_path too
info_payload = mock_torrent.to_dict() | {"save_path": "/downloads"}
def response(kind: str):
r = MagicMock()
r.status_code = 200
r.raise_for_status = MagicMock()
if kind == "info":
r.json.return_value = [info_payload]
elif kind == "properties":
r.json.return_value = {"save_path": "/downloads"}
elif kind == "files":
r.json.return_value = [{"name": "Some Torrent/book.epub"}]
else:
raise AssertionError("unknown")
return r
mock_client_instance = MagicMock()
def get_side_effect(url, params=None, timeout=None):
if url.endswith("/api/v2/torrents/info"):
return response("info")
if url.endswith("/api/v2/torrents/properties"):
return response("properties")
if url.endswith("/api/v2/torrents/files"):
return response("files")
raise AssertionError(f"unexpected url: {url}")
mock_client_instance._session.get.side_effect = get_side_effect
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
status = client.get_status("abc123")
assert status.complete is True
assert status.file_path == "/downloads/Some Torrent"
def test_get_status_not_found(self, monkeypatch):
"""Test status for non-existent torrent."""
config_values = {
@@ -256,8 +319,12 @@ class TestQBittorrentClientGetStatus:
)
mock_client_instance = MagicMock()
# Mock the session.get for _get_torrents_info - empty list
mock_client_instance._session.get.return_value = create_mock_session_response([])
# hashes query empty -> category list empty -> full list empty
mock_client_instance._session.get.side_effect = [
create_mock_session_response([], status_code=200),
create_mock_session_response([], status_code=200),
create_mock_session_response([], status_code=200),
]
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
@@ -269,6 +336,7 @@ class TestQBittorrentClientGetStatus:
status = client.get_status("nonexistent")
assert status.state_value == "error"
assert status.message is not None
assert "not found" in status.message.lower()
def test_get_status_stalled(self, monkeypatch):
@@ -287,7 +355,7 @@ class TestQBittorrentClientGetStatus:
mock_torrent = MockTorrent(progress=0.3, state="stalledDL")
mock_client_instance = MagicMock()
# Mock the session.get for _get_torrents_info
mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent])
mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
@@ -299,6 +367,7 @@ class TestQBittorrentClientGetStatus:
status = client.get_status("abc123")
assert status.state_value == "downloading"
assert status.message is not None
assert "stalled" in status.message.lower()
def test_get_status_paused(self, monkeypatch):
@@ -317,7 +386,7 @@ class TestQBittorrentClientGetStatus:
mock_torrent = MockTorrent(progress=0.5, state="pausedDL")
mock_client_instance = MagicMock()
# Mock the session.get for _get_torrents_info
mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent])
mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
@@ -346,7 +415,7 @@ class TestQBittorrentClientGetStatus:
mock_torrent = MockTorrent(progress=0.1, state="error")
mock_client_instance = MagicMock()
# Mock the session.get for _get_torrents_info
mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent])
mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
@@ -380,6 +449,8 @@ class TestQBittorrentClientAddDownload:
mock_client_instance = MagicMock()
mock_client_instance.torrents_add.return_value = "Ok."
mock_client_instance.torrents_info.return_value = [mock_torrent]
# Used by the properties check
mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
@@ -392,6 +463,7 @@ class TestQBittorrentClientAddDownload:
result = client.add_download(magnet, "Test Download")
assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
assert mock_client_instance._session.get.call_count >= 1
def test_add_download_creates_category(self, monkeypatch):
"""Test that add_download creates category if needed."""
@@ -399,7 +471,7 @@ class TestQBittorrentClientAddDownload:
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "cwabd",
"QBITTORRENT_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
@@ -412,6 +484,8 @@ class TestQBittorrentClientAddDownload:
mock_client_instance = MagicMock()
mock_client_instance.torrents_add.return_value = "Ok."
mock_client_instance.torrents_info.return_value = [mock_torrent]
# Used by the properties check
mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
@@ -423,7 +497,7 @@ class TestQBittorrentClientAddDownload:
magnet = f"magnet:?xt=urn:btih:{valid_hash}&dn=test"
client.add_download(magnet, "Test")
mock_client_instance.torrents_create_category.assert_called_once_with(name="cwabd")
mock_client_instance.torrents_create_category.assert_called_once_with(name="books")
class TestQBittorrentClientRemove:
@@ -486,6 +560,157 @@ class TestQBittorrentClientRemove:
assert result is False
class TestQBittorrentClientGetDownloadPath:
"""Tests for QBittorrentClient.get_download_path()."""
def test_get_download_path_prefers_content_path(self, monkeypatch):
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "test",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
mock_torrent = MockTorrent(
hash_val="abc123",
content_path="/downloads/some/book.epub",
)
mock_client_instance = MagicMock()
mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
path = client.get_download_path("abc123")
assert path == "/downloads/some/book.epub"
def test_get_download_path_does_not_accept_content_path_equal_save_path(self, monkeypatch):
"""content_path == save_path indicates a path error."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "test",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
mock_torrent = MockTorrent(
hash_val="abc123",
content_path="/downloads",
)
# emulate qbit reporting save_path too
setattr(mock_torrent, "save_path", "/downloads")
def response(kind: str):
r = MagicMock()
r.status_code = 200
r.raise_for_status = MagicMock()
if kind == "info":
r.json.return_value = [mock_torrent.to_dict() | {"save_path": "/downloads"}]
elif kind == "properties":
r.json.return_value = {"save_path": "/downloads"}
elif kind == "files":
r.json.return_value = [{"name": "Some Torrent/book.epub"}]
else:
raise AssertionError("unknown")
return r
mock_client_instance = MagicMock()
def get_side_effect(url, params=None, timeout=None):
if url.endswith("/api/v2/torrents/info"):
return response("info")
if url.endswith("/api/v2/torrents/properties"):
return response("properties")
if url.endswith("/api/v2/torrents/files"):
return response("files")
raise AssertionError(f"unexpected url: {url}")
mock_client_instance._session.get.side_effect = get_side_effect
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
path = client.get_download_path("abc123")
assert path == "/downloads/Some Torrent"
def test_get_download_path_derives_from_files_when_missing_content_path(self, monkeypatch):
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "test",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
# Simulate emulator: no content_path, but we can derive from properties+files
mock_torrent = MockTorrent(
hash_val="abc123",
content_path="",
name="Some Torrent",
)
def json_for(response_kind: str):
if response_kind == "info":
return [mock_torrent.to_dict()]
if response_kind == "properties":
return {"save_path": "/downloads"}
if response_kind == "files":
return [{"name": "Some Torrent/book.epub"}]
raise AssertionError("unknown")
def response(kind: str):
r = MagicMock()
r.status_code = 200
r.raise_for_status = MagicMock()
r.json.return_value = json_for(kind)
return r
mock_client_instance = MagicMock()
def get_side_effect(url, params=None, timeout=None):
if url.endswith("/api/v2/torrents/info"):
return response("info")
if url.endswith("/api/v2/torrents/properties"):
return response("properties")
if url.endswith("/api/v2/torrents/files"):
return response("files")
raise AssertionError(f"unexpected url: {url}")
mock_client_instance._session.get.side_effect = get_side_effect
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
path = client.get_download_path("abc123")
assert path == "/downloads/Some Torrent"
class TestQBittorrentClientFindExisting:
"""Tests for QBittorrentClient.find_existing()."""
@@ -509,7 +734,7 @@ class TestQBittorrentClientFindExisting:
)
mock_client_instance = MagicMock()
# Mock the session.get for _get_torrents_info
mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent])
mock_client_instance._session.get.return_value = create_mock_session_response([mock_torrent], status_code=200)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
@@ -540,8 +765,12 @@ class TestQBittorrentClientFindExisting:
)
mock_client_instance = MagicMock()
# Mock the session.get for _get_torrents_info - empty list
mock_client_instance._session.get.return_value = create_mock_session_response([])
# First call: hashes query returns empty. Second call (category listing) also empty.
mock_client_instance._session.get.side_effect = [
create_mock_session_response([], status_code=200),
create_mock_session_response([], status_code=200),
create_mock_session_response([], status_code=200),
]
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
@@ -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)
+3
View File
@@ -355,6 +355,7 @@ class TestRTorrentClientGetStatus:
1024000,
0,
"cwabd",
0,
]
]
@@ -401,6 +402,7 @@ class TestRTorrentClientGetStatus:
0,
2048000,
"cwabd",
1,
]
]
mock_rpc.d.get_base_path.return_value = "/downloads/test-torrent"
@@ -483,6 +485,7 @@ class TestRTorrentClientGetStatus:
1048576,
0,
"cwabd",
0,
]
]
+19 -18
View File
@@ -95,7 +95,7 @@ class TestSABnzbdClientTestConnection:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -126,7 +126,7 @@ class TestSABnzbdClientTestConnection:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "wrong",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -156,7 +156,7 @@ class TestSABnzbdClientGetStatus:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -205,7 +205,7 @@ class TestSABnzbdClientGetStatus:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -222,7 +222,8 @@ class TestSABnzbdClientGetStatus:
{
"nzo_id": "SABnzbd_nzo_abc123",
"status": "Completed",
"storage": "/downloads/complete/book",
"storage": "/downloads/complete/book/Sorted/Subfolder",
"name": "book",
}
]
}
@@ -245,7 +246,7 @@ class TestSABnzbdClientGetStatus:
assert status.progress == 100.0
assert status.state_value == "complete"
assert status.complete is True
assert status.file_path == "/downloads/complete/book"
assert status.file_path == "/downloads/complete/book" # resolved to job root
def test_get_status_complete_empty_storage(self, monkeypatch):
"""Test status for completed NZB with empty storage path.
@@ -256,7 +257,7 @@ class TestSABnzbdClientGetStatus:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -303,7 +304,7 @@ class TestSABnzbdClientGetStatus:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -348,7 +349,7 @@ class TestSABnzbdClientGetStatus:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -383,7 +384,7 @@ class TestSABnzbdClientGetStatus:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -427,7 +428,7 @@ class TestSABnzbdClientGetStatus:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -475,7 +476,7 @@ class TestSABnzbdClientAddDownload:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -509,7 +510,7 @@ class TestSABnzbdClientAddDownload:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -545,7 +546,7 @@ class TestSABnzbdClientRemove:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -577,7 +578,7 @@ class TestSABnzbdClientRemove:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -620,7 +621,7 @@ class TestSABnzbdClientFindExisting:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -667,7 +668,7 @@ class TestSABnzbdClientFindExisting:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
@@ -714,7 +715,7 @@ class TestSABnzbdClientFindExisting:
config_values = {
"SABNZBD_URL": "http://localhost:8080",
"SABNZBD_API_KEY": "abc123",
"SABNZBD_CATEGORY": "cwabd",
"SABNZBD_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",