mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
Remote path mappings, Client handling improvements (#481)
This commit is contained in:
@@ -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)}):
|
||||
|
||||
@@ -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