mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-01 22:06:39 +01:00
Results were deduplicated on `guid` alone. When one tracker is configured in Prowlarr as several indexer entries differing only by a server-side search filter, all of them return the same guid for the same torrent, so every entry but the first was silently discarded. Freeleech and other filter-specific releases became invisible, replaced by the unfiltered entry's copy, and which copy survived depended on query ordering rather than user intent. Include the indexer id in the dedup key so the entries stay distinct. Release.source_id is qualified the same way. It keys the release cache and becomes the download task id, so rows sharing a guid would otherwise collide and a grab would route through whichever entry cached last, defeating the point of showing them separately. The handler's task matcher still accepts a bare guid or infoUrl so tasks queued before this change still resolve. Ordering now follows the priority already configured in Prowlarr (1-50, lower preferred) rather than a new setting, since users curate that ranking there and filtered entries are typically ranked ahead of their unfiltered counterparts. The enabled-indexer list is fetched once and reused for the enrichment check, so this costs no extra round trip. Releases carry extra.indexer_priority, and the sort dropdown gains an "Indexer priority" entry, ascending, alongside the existing alphabetical "Indexer" sort; SortOption grew a default_direction for that, defaulting to desc so "Peers" is unchanged. PROWLARR_COLLAPSE_DUPLICATES (default off) optionally collapses a release back to one row, resolved by the same Prowlarr priority. Left off, every entry that carried a release keeps its own row, which is what makes filtered results visible again. Identity handling is defensive about partial payloads: a result that cannot be identified is never dropped or merged, and collapse only merges on a strong identifier (guid/downloadUrl/magnetUrl/infoUrl) because merging on title alone would discard genuinely different releases that share a name. Fixes #1137 Co-authored-by: delize <4028612+delize@users.noreply.github.com>
1641 lines
60 KiB
Python
1641 lines
60 KiB
Python
"""
|
|
Unit tests for the Prowlarr download handler.
|
|
|
|
These tests mock the download clients to test the handler logic
|
|
without requiring running services.
|
|
"""
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
from threading import Event
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from shelfmark.core.models import DownloadTask
|
|
from shelfmark.download.clients import (
|
|
DownloadState,
|
|
DownloadStatus,
|
|
)
|
|
from shelfmark.release_sources import Release, ReleaseProtocol
|
|
from shelfmark.release_sources.prowlarr.cache import cache_release, remove_release
|
|
from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler
|
|
from shelfmark.release_sources.prowlarr.utils import build_source_id, get_protocol
|
|
|
|
|
|
class ProgressRecorder:
|
|
"""Records progress and status updates during download."""
|
|
|
|
def __init__(self):
|
|
self.progress_values: list[float] = []
|
|
self.status_updates: list[tuple[str, str | None]] = []
|
|
|
|
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))
|
|
|
|
@property
|
|
def last_status(self) -> str | None:
|
|
return self.status_updates[-1][0] if self.status_updates else None
|
|
|
|
@property
|
|
def last_message(self) -> str | None:
|
|
return self.status_updates[-1][1] if self.status_updates else None
|
|
|
|
@property
|
|
def statuses(self) -> list[str]:
|
|
return [s[0] for s in self.status_updates]
|
|
|
|
|
|
class TestGetProtocol:
|
|
"""Tests for the get_protocol function."""
|
|
|
|
def test_get_protocol_torrent(self):
|
|
"""Test detecting torrent protocol."""
|
|
result = {"protocol": "torrent"}
|
|
assert get_protocol(result) == "torrent"
|
|
|
|
def test_get_protocol_usenet(self):
|
|
"""Test detecting usenet protocol."""
|
|
result = {"protocol": "usenet"}
|
|
assert get_protocol(result) == "usenet"
|
|
|
|
def test_get_protocol_unknown(self):
|
|
"""Test unknown protocol."""
|
|
result = {"protocol": "ftp"}
|
|
assert get_protocol(result) == "unknown"
|
|
|
|
def test_get_protocol_empty(self):
|
|
"""Test empty protocol."""
|
|
result = {}
|
|
assert get_protocol(result) == "unknown"
|
|
|
|
def test_get_protocol_case_insensitive(self):
|
|
"""Test protocol detection is case insensitive."""
|
|
assert get_protocol({"protocol": "TORRENT"}) == "torrent"
|
|
assert get_protocol({"protocol": "Usenet"}) == "usenet"
|
|
assert get_protocol({"protocol": "USENET"}) == "usenet"
|
|
|
|
|
|
class TestProwlarrHandlerDownloadErrors:
|
|
"""Tests for error handling in ProwlarrHandler.download()."""
|
|
|
|
def test_download_fails_without_cached_release(self):
|
|
"""Test that download fails when release is not in cache."""
|
|
with patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value=None,
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="non-existent-id",
|
|
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 recorder.last_message is not None
|
|
assert "could not be refreshed" in recorder.last_message
|
|
|
|
def test_download_fails_clearly_when_cache_miss_cannot_refresh(self):
|
|
"""Prowlarr retry URLs are not durable; cache misses must refresh by identity."""
|
|
with (
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
|
|
return_value=[],
|
|
),
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="retry-context-release",
|
|
source="prowlarr",
|
|
title="Recovered Release",
|
|
retry_download_url="magnet:?xt=urn:btih:abc123",
|
|
retry_download_protocol="torrent",
|
|
retry_release_name="Recovered Release",
|
|
retry_seeding_time_limit_minutes=60,
|
|
retry_ratio_limit=1.5,
|
|
)
|
|
recorder = ProgressRecorder()
|
|
|
|
request = handler._resolve_download(task, recorder.status_callback)
|
|
|
|
assert request is None
|
|
assert recorder.last_status == "error"
|
|
assert recorder.last_message is not None
|
|
assert "could not be refreshed" in recorder.last_message
|
|
|
|
def test_cache_miss_re_resolves_and_uses_fresh_download_url(self):
|
|
"""Cache miss should re-query Prowlarr and use a fresh exact-match URL."""
|
|
task_id = "fresh-guid-1"
|
|
fresh_url = "https://prowlarr.example.com/download/fresh-token"
|
|
|
|
def mock_search(*_args, **_kwargs):
|
|
cache_release(
|
|
task_id,
|
|
{
|
|
"guid": task_id,
|
|
"protocol": "torrent",
|
|
"downloadUrl": fresh_url,
|
|
"title": "Fresh Release",
|
|
},
|
|
)
|
|
return [
|
|
Release(
|
|
source="prowlarr",
|
|
source_id=task_id,
|
|
title="Fresh Release",
|
|
info_url="https://tracker.example.com/release/1",
|
|
protocol=ReleaseProtocol.TORRENT,
|
|
)
|
|
]
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.name = "qbittorrent"
|
|
mock_client.find_existing.return_value = None
|
|
mock_client.add_download.return_value = "download_id"
|
|
|
|
remove_release(task_id)
|
|
try:
|
|
with (
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
|
|
side_effect=mock_search,
|
|
) as mock_search_method,
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_client",
|
|
return_value=mock_client,
|
|
),
|
|
patch.object(ProwlarrHandler, "_poll_and_complete", return_value=None),
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id=task_id,
|
|
source="prowlarr",
|
|
title="Fresh Book",
|
|
retry_source_context={"indexer": "MyIndexer"},
|
|
)
|
|
recorder = ProgressRecorder()
|
|
|
|
handler.download(
|
|
task=task,
|
|
cancel_flag=Event(),
|
|
progress_callback=recorder.progress_callback,
|
|
status_callback=recorder.status_callback,
|
|
)
|
|
|
|
mock_search_method.assert_called_once()
|
|
assert mock_client.add_download.call_args.kwargs["url"] == fresh_url
|
|
finally:
|
|
remove_release(task_id)
|
|
|
|
def test_stale_cached_url_add_failure_re_resolves_once_and_succeeds(self):
|
|
"""Expired cached proxy URL should be refreshed once after qBittorrent hash failure."""
|
|
task_id = "stale-guid-1"
|
|
stale_url = "https://prowlarr.example.com/download/stale-token"
|
|
fresh_url = "https://prowlarr.example.com/download/fresh-token"
|
|
|
|
def mock_search(*_args, **_kwargs):
|
|
cache_release(
|
|
task_id,
|
|
{
|
|
"guid": task_id,
|
|
"protocol": "torrent",
|
|
"downloadUrl": fresh_url,
|
|
"title": "Fresh Release",
|
|
},
|
|
)
|
|
return [
|
|
Release(
|
|
source="prowlarr",
|
|
source_id=task_id,
|
|
title="Fresh Release",
|
|
protocol=ReleaseProtocol.TORRENT,
|
|
)
|
|
]
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.name = "qbittorrent"
|
|
mock_client.find_existing.return_value = None
|
|
mock_client.add_download.side_effect = [
|
|
RuntimeError("Could not determine torrent hash from URL"),
|
|
"download_id",
|
|
]
|
|
|
|
cache_release(
|
|
task_id,
|
|
{
|
|
"guid": task_id,
|
|
"protocol": "torrent",
|
|
"downloadUrl": stale_url,
|
|
"title": "Stale Release",
|
|
},
|
|
)
|
|
try:
|
|
with (
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
|
|
side_effect=mock_search,
|
|
) as mock_search_method,
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_client",
|
|
return_value=mock_client,
|
|
),
|
|
patch.object(ProwlarrHandler, "_poll_and_complete", return_value=None),
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(task_id=task_id, source="prowlarr", title="Stale Book")
|
|
recorder = ProgressRecorder()
|
|
|
|
handler.download(
|
|
task=task,
|
|
cancel_flag=Event(),
|
|
progress_callback=recorder.progress_callback,
|
|
status_callback=recorder.status_callback,
|
|
)
|
|
|
|
assert mock_client.add_download.call_count == 2
|
|
assert mock_client.add_download.call_args_list[0].kwargs["url"] == stale_url
|
|
assert mock_client.add_download.call_args_list[1].kwargs["url"] == fresh_url
|
|
mock_search_method.assert_called_once()
|
|
finally:
|
|
remove_release(task_id)
|
|
|
|
def test_refresh_without_exact_match_fails_clearly(self):
|
|
"""Refresh must not pick a different Prowlarr result when identity differs."""
|
|
task_id = "missing-guid-1"
|
|
other_id = "other-guid-1"
|
|
|
|
def mock_search(*_args, **_kwargs):
|
|
cache_release(
|
|
other_id,
|
|
{
|
|
"guid": other_id,
|
|
"protocol": "torrent",
|
|
"downloadUrl": "https://prowlarr.example.com/download/other",
|
|
"title": "Other Release",
|
|
},
|
|
)
|
|
return [
|
|
Release(
|
|
source="prowlarr",
|
|
source_id=other_id,
|
|
title="Other Release",
|
|
protocol=ReleaseProtocol.TORRENT,
|
|
)
|
|
]
|
|
|
|
remove_release(task_id)
|
|
remove_release(other_id)
|
|
try:
|
|
with patch(
|
|
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
|
|
side_effect=mock_search,
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(task_id=task_id, source="prowlarr", title="Missing Book")
|
|
recorder = ProgressRecorder()
|
|
|
|
request = handler._resolve_download(task, recorder.status_callback)
|
|
|
|
assert request is None
|
|
assert recorder.last_status == "error"
|
|
assert recorder.last_message is not None
|
|
assert "could not be refreshed" in recorder.last_message
|
|
finally:
|
|
remove_release(task_id)
|
|
remove_release(other_id)
|
|
|
|
def test_magnet_result_does_not_trigger_prowlarr_url_refresh(self):
|
|
"""Magnet failures should not be treated as expired Prowlarr proxy URLs."""
|
|
task_id = "magnet-guid-1"
|
|
magnet = "magnet:?xt=urn:btih:abc123&dn=test"
|
|
mock_client = MagicMock()
|
|
mock_client.name = "qbittorrent"
|
|
mock_client.find_existing.return_value = None
|
|
mock_client.add_download.side_effect = RuntimeError(
|
|
"Could not determine torrent hash from URL"
|
|
)
|
|
|
|
cache_release(
|
|
task_id,
|
|
{
|
|
"guid": task_id,
|
|
"protocol": "torrent",
|
|
"downloadUrl": "https://prowlarr.example.com/download/stale-token",
|
|
"magnetUrl": magnet,
|
|
"title": "Magnet Release",
|
|
},
|
|
)
|
|
try:
|
|
with (
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
|
|
return_value=[],
|
|
) as mock_search_method,
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_client",
|
|
return_value=mock_client,
|
|
),
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(task_id=task_id, source="prowlarr", title="Magnet Book")
|
|
recorder = ProgressRecorder()
|
|
|
|
result = handler.download(
|
|
task=task,
|
|
cancel_flag=Event(),
|
|
progress_callback=recorder.progress_callback,
|
|
status_callback=recorder.status_callback,
|
|
)
|
|
|
|
assert result is None
|
|
assert mock_client.add_download.call_count == 1
|
|
assert mock_client.add_download.call_args.kwargs["url"] == magnet
|
|
mock_search_method.assert_not_called()
|
|
finally:
|
|
remove_release(task_id)
|
|
|
|
def test_download_fails_without_download_url(self):
|
|
"""Test that download fails when release has no download URL."""
|
|
with patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value={
|
|
"protocol": "torrent",
|
|
"title": "Test Release",
|
|
# No downloadUrl or magnetUrl
|
|
},
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="no-url-release",
|
|
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 recorder.last_message is not None
|
|
assert "url" in recorder.last_message.lower()
|
|
|
|
def test_download_fails_unknown_protocol(self):
|
|
"""Test that download fails with unknown protocol."""
|
|
with patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value={
|
|
"protocol": "ftp",
|
|
"downloadUrl": "ftp://example.com/file.zip",
|
|
},
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="unknown-protocol",
|
|
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 recorder.last_message is not None
|
|
assert "protocol" in recorder.last_message.lower()
|
|
|
|
def test_download_fails_no_client_configured(self):
|
|
"""Test that download fails when no client is configured."""
|
|
with (
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value={
|
|
"protocol": "torrent",
|
|
"downloadUrl": "magnet:?xt=urn:btih:abc123",
|
|
},
|
|
),
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_client",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.list_configured_clients",
|
|
return_value=[],
|
|
),
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="no-client",
|
|
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 recorder.last_message is not None
|
|
assert "client" in recorder.last_message.lower()
|
|
|
|
|
|
class TestProwlarrHandlerSeedCriteria:
|
|
"""Tests for seed criteria passed through from Prowlarr."""
|
|
|
|
def test_resolve_download_ignores_torznab_minimum_seed_criteria(self):
|
|
with patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value={
|
|
"protocol": "torrent",
|
|
"title": "Test Release",
|
|
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
|
"minimumSeedTime": 259200,
|
|
"minimumRatio": 1,
|
|
},
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="seed-time-conversion",
|
|
source="prowlarr",
|
|
title="Test Book",
|
|
)
|
|
|
|
request = handler._resolve_download(task, lambda *_: None)
|
|
|
|
assert request is not None
|
|
assert request.seeding_time_limit is None
|
|
assert request.ratio_limit is None
|
|
|
|
def test_resolve_download_uses_configured_seed_time_minutes(self):
|
|
with (
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value={
|
|
"protocol": "torrent",
|
|
"title": "Test Release",
|
|
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
|
"configuredSeedTimeMinutes": 7200,
|
|
"configuredRatioLimit": 2,
|
|
"minimumSeedTime": 259200,
|
|
"minimumRatio": 1,
|
|
},
|
|
),
|
|
patch("shelfmark.release_sources.prowlarr.handler.config.get", return_value=True),
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="configured-seed-time",
|
|
source="prowlarr",
|
|
title="Test Book",
|
|
)
|
|
|
|
request = handler._resolve_download(task, lambda *_: None)
|
|
|
|
assert request is not None
|
|
assert request.seeding_time_limit == 7200
|
|
assert request.ratio_limit == 2.0
|
|
|
|
def test_resolve_download_ignores_configured_seed_time_when_disabled(self):
|
|
with (
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value={
|
|
"protocol": "torrent",
|
|
"title": "Test Release",
|
|
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
|
"configuredSeedTimeMinutes": 7200,
|
|
"configuredRatioLimit": 2,
|
|
},
|
|
),
|
|
patch("shelfmark.release_sources.prowlarr.handler.config.get", return_value=False),
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="configured-seed-time-disabled",
|
|
source="prowlarr",
|
|
title="Test Book",
|
|
)
|
|
|
|
request = handler._resolve_download(task, lambda *_: None)
|
|
|
|
assert request is not None
|
|
assert request.seeding_time_limit is None
|
|
assert request.ratio_limit is None
|
|
|
|
def test_resolve_download_falls_back_to_prowlarr_when_enrichment_missing(self):
|
|
"""Regression test for #795: when search-time enrichment is missing,
|
|
share limits are re-resolved from Prowlarr at grab time."""
|
|
mock_client = MagicMock()
|
|
mock_client.get_indexer_seed_settings.return_value = {
|
|
5: {"seeding_time_limit_minutes": 4320, "ratio_limit": 1.0}
|
|
}
|
|
|
|
def config_get(key, default=None):
|
|
return True if key == "PROWLARR_USE_SEED_PREFERENCES" else default
|
|
|
|
with (
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value={
|
|
"protocol": "torrent",
|
|
"title": "Test Release",
|
|
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
|
"indexerId": 5,
|
|
},
|
|
),
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.config.get",
|
|
side_effect=config_get,
|
|
),
|
|
patch.object(
|
|
ProwlarrHandler,
|
|
"_build_prowlarr_client",
|
|
return_value=mock_client,
|
|
),
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="seed-time-fallback",
|
|
source="prowlarr",
|
|
title="Test Book",
|
|
)
|
|
|
|
request = handler._resolve_download(task, lambda *_: None)
|
|
|
|
assert request is not None
|
|
assert request.seeding_time_limit == 4320
|
|
assert request.ratio_limit == 1.0
|
|
mock_client.get_indexer_seed_settings.assert_called_once_with(restrict_to=[5])
|
|
|
|
def test_resolve_download_fallback_failure_leaves_limits_unset(self):
|
|
mock_client = MagicMock()
|
|
mock_client.get_indexer_seed_settings.side_effect = RuntimeError("prowlarr down")
|
|
|
|
def config_get(key, default=None):
|
|
return True if key == "PROWLARR_USE_SEED_PREFERENCES" else default
|
|
|
|
with (
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value={
|
|
"protocol": "torrent",
|
|
"title": "Test Release",
|
|
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
|
"indexerId": 5,
|
|
},
|
|
),
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.config.get",
|
|
side_effect=config_get,
|
|
),
|
|
patch.object(
|
|
ProwlarrHandler,
|
|
"_build_prowlarr_client",
|
|
return_value=mock_client,
|
|
),
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="seed-time-fallback-failure",
|
|
source="prowlarr",
|
|
title="Test Book",
|
|
)
|
|
|
|
request = handler._resolve_download(task, lambda *_: None)
|
|
|
|
assert request is not None
|
|
assert request.seeding_time_limit is None
|
|
assert request.ratio_limit is None
|
|
|
|
def test_resolve_download_skips_fallback_when_enrichment_present(self):
|
|
with (
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value={
|
|
"protocol": "torrent",
|
|
"title": "Test Release",
|
|
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
|
"indexerId": 5,
|
|
"configuredSeedTimeMinutes": 7200,
|
|
},
|
|
),
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.config.get",
|
|
side_effect=lambda key, default=None: (
|
|
True if key == "PROWLARR_USE_SEED_PREFERENCES" else default
|
|
),
|
|
),
|
|
patch.object(ProwlarrHandler, "_build_prowlarr_client") as mock_builder,
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="seed-time-no-fallback",
|
|
source="prowlarr",
|
|
title="Test Book",
|
|
)
|
|
|
|
request = handler._resolve_download(task, lambda *_: None)
|
|
|
|
assert request is not None
|
|
assert request.seeding_time_limit == 7200
|
|
mock_builder.assert_not_called()
|
|
|
|
def test_download_passes_seed_limits_to_client(self):
|
|
mock_client = MagicMock()
|
|
mock_client.name = "qbittorrent"
|
|
mock_client.find_existing.return_value = None
|
|
mock_client.add_download.return_value = "download_id"
|
|
|
|
with (
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value={
|
|
"protocol": "torrent",
|
|
"title": "Test Release",
|
|
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
|
"configuredSeedTimeMinutes": 7200,
|
|
"configuredRatioLimit": 1.25,
|
|
"minimumSeedTime": 259200,
|
|
"minimumRatio": 1,
|
|
},
|
|
),
|
|
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", return_value=True),
|
|
patch.object(
|
|
ProwlarrHandler,
|
|
"_poll_and_complete",
|
|
return_value=None,
|
|
),
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="seed-limit-pass-through",
|
|
source="prowlarr",
|
|
title="Test Book",
|
|
)
|
|
cancel_flag = Event()
|
|
recorder = ProgressRecorder()
|
|
|
|
handler.download(
|
|
task=task,
|
|
cancel_flag=cancel_flag,
|
|
progress_callback=recorder.progress_callback,
|
|
status_callback=recorder.status_callback,
|
|
)
|
|
|
|
call_kwargs = mock_client.add_download.call_args.kwargs
|
|
assert call_kwargs["seeding_time_limit"] == 7200
|
|
assert call_kwargs["ratio_limit"] == 1.25
|
|
|
|
|
|
class TestProwlarrHandlerExistingDownload:
|
|
"""Tests for handling existing downloads."""
|
|
|
|
def test_prefers_magnet_url_for_torrents(self):
|
|
"""If both downloadUrl and magnetUrl exist, torrents should use magnetUrl."""
|
|
mock_client = MagicMock()
|
|
mock_client.name = "qbittorrent"
|
|
mock_client.find_existing.return_value = None
|
|
|
|
with (
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value={
|
|
"protocol": "torrent",
|
|
"downloadUrl": "https://prowlarr.example.com/api/v1/indexer/1/download/123",
|
|
"magnetUrl": "magnet:?xt=urn:btih:abc123&dn=test",
|
|
"title": "Test Release",
|
|
},
|
|
),
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_client",
|
|
return_value=mock_client,
|
|
),
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.remove_release",
|
|
),
|
|
patch.object(
|
|
ProwlarrHandler,
|
|
"_poll_and_complete",
|
|
return_value=None,
|
|
),
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="torrent-prefers-magnet", source="prowlarr", title="Test Book"
|
|
)
|
|
cancel_flag = Event()
|
|
recorder = ProgressRecorder()
|
|
|
|
handler.download(
|
|
task=task,
|
|
cancel_flag=cancel_flag,
|
|
progress_callback=recorder.progress_callback,
|
|
status_callback=recorder.status_callback,
|
|
)
|
|
|
|
assert mock_client.find_existing.call_count == 1
|
|
called_url = mock_client.find_existing.call_args.args[0]
|
|
assert called_url == "magnet:?xt=urn:btih:abc123&dn=test"
|
|
|
|
def test_prefers_download_url_for_usenet(self):
|
|
"""If both downloadUrl and magnetUrl exist, usenet should use downloadUrl."""
|
|
mock_client = MagicMock()
|
|
mock_client.name = "sabnzbd"
|
|
mock_client.find_existing.return_value = None
|
|
|
|
with (
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value={
|
|
"protocol": "usenet",
|
|
"downloadUrl": "https://prowlarr.example.com/api/v1/indexer/1/download/456",
|
|
"magnetUrl": "magnet:?xt=urn:btih:abc123&dn=test",
|
|
"title": "Test Release",
|
|
},
|
|
),
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_client",
|
|
return_value=mock_client,
|
|
),
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.remove_release",
|
|
),
|
|
patch.object(
|
|
ProwlarrHandler,
|
|
"_poll_and_complete",
|
|
return_value=None,
|
|
),
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="usenet-prefers-download", source="prowlarr", title="Test Book"
|
|
)
|
|
cancel_flag = Event()
|
|
recorder = ProgressRecorder()
|
|
|
|
handler.download(
|
|
task=task,
|
|
cancel_flag=cancel_flag,
|
|
progress_callback=recorder.progress_callback,
|
|
status_callback=recorder.status_callback,
|
|
)
|
|
|
|
assert mock_client.find_existing.call_count == 1
|
|
called_url = mock_client.find_existing.call_args.args[0]
|
|
assert called_url == "https://prowlarr.example.com/api/v1/indexer/1/download/456"
|
|
|
|
def test_uses_existing_complete_download(self):
|
|
"""Test that handler uses existing complete download."""
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
# Create a test file
|
|
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()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.name = "test_client"
|
|
mock_client.find_existing.return_value = (
|
|
"existing_id",
|
|
DownloadStatus(
|
|
progress=100,
|
|
state=DownloadState.COMPLETE,
|
|
message="Complete",
|
|
complete=True,
|
|
file_path=str(source_file),
|
|
),
|
|
)
|
|
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,
|
|
),
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="existing-complete",
|
|
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 "resolving" in recorder.statuses
|
|
# Should NOT have called add_download
|
|
mock_client.add_download.assert_not_called()
|
|
|
|
|
|
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:
|
|
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] >= 3:
|
|
return DownloadStatus(
|
|
progress=100,
|
|
state=DownloadState.COMPLETE,
|
|
message="Complete",
|
|
complete=True,
|
|
file_path=str(source_file),
|
|
)
|
|
return DownloadStatus(
|
|
progress=poll_count[0] * 30,
|
|
state=DownloadState.DOWNLOADING,
|
|
message=None,
|
|
complete=False,
|
|
file_path=None,
|
|
download_speed=1024000,
|
|
eta=60,
|
|
)
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.name = "test_client"
|
|
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, # Speed up tests
|
|
),
|
|
):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(
|
|
task_id="poll-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 len(recorder.progress_values) >= 3
|
|
|
|
def test_handles_error_during_download(self):
|
|
"""Test that handler handles error state during download."""
|
|
mock_client = MagicMock()
|
|
mock_client.name = "test_client"
|
|
mock_client.find_existing.return_value = None
|
|
mock_client.add_download.return_value = "download_id"
|
|
mock_client.get_status.return_value = DownloadStatus(
|
|
progress=50,
|
|
state=DownloadState.ERROR,
|
|
message="Disk full",
|
|
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="error-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"
|
|
mock_client.remove.assert_not_called()
|
|
|
|
|
|
class TestProwlarrHandlerCancellation:
|
|
"""Tests for download cancellation."""
|
|
|
|
def test_cancellation_does_not_remove_torrent(self):
|
|
"""Test that torrent cancellation does not remove from client."""
|
|
mock_client = MagicMock()
|
|
mock_client.name = "test_client"
|
|
mock_client.find_existing.return_value = None
|
|
mock_client.add_download.return_value = "download_id"
|
|
mock_client.get_status.return_value = DownloadStatus(
|
|
progress=50,
|
|
state=DownloadState.DOWNLOADING,
|
|
message="Downloading",
|
|
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="cancel-test",
|
|
source="prowlarr",
|
|
title="Test Book",
|
|
)
|
|
cancel_flag = Event()
|
|
recorder = ProgressRecorder()
|
|
|
|
# Set cancel immediately
|
|
cancel_flag.set()
|
|
|
|
result = handler.download(
|
|
task=task,
|
|
cancel_flag=cancel_flag,
|
|
progress_callback=recorder.progress_callback,
|
|
status_callback=recorder.status_callback,
|
|
)
|
|
|
|
assert result is None
|
|
assert "cancelled" in recorder.statuses
|
|
mock_client.remove.assert_not_called()
|
|
|
|
|
|
class TestProwlarrHandlerCancel:
|
|
"""Tests for ProwlarrHandler.cancel()."""
|
|
|
|
def test_cancel_removes_from_cache(self):
|
|
"""Test that cancel removes release from cache."""
|
|
with patch("shelfmark.release_sources.prowlarr.handler.remove_release") as mock_remove:
|
|
handler = ProwlarrHandler()
|
|
result = handler.cancel("test-task-id")
|
|
|
|
assert result is True
|
|
mock_remove.assert_called_once_with("test-task-id")
|
|
|
|
def test_cancel_handles_missing_task(self):
|
|
"""Test that cancel handles non-existent task gracefully."""
|
|
with patch("shelfmark.release_sources.prowlarr.handler.remove_release"):
|
|
handler = ProwlarrHandler()
|
|
result = handler.cancel("nonexistent-task-id")
|
|
|
|
assert result is True
|
|
|
|
|
|
class TestProwlarrHandlerFileStaging:
|
|
"""Tests for file staging behavior."""
|
|
|
|
def test_stages_single_file(self):
|
|
"""Test staging a single file download."""
|
|
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()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.name = "test_client"
|
|
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=str(source_file),
|
|
)
|
|
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="staging-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
|
|
staged_file = Path(result)
|
|
assert staged_file.exists()
|
|
assert staged_file.read_text() == "test content"
|
|
|
|
def test_stages_directory(self):
|
|
"""Test staging a directory download."""
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
source_dir = Path(tmp_dir) / "source" / "book_folder"
|
|
source_dir.mkdir(parents=True)
|
|
(source_dir / "book.epub").write_text("epub content")
|
|
(source_dir / "cover.jpg").write_bytes(b"image data")
|
|
|
|
staging_dir = Path(tmp_dir) / "staging"
|
|
staging_dir.mkdir()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.name = "test_client"
|
|
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=str(source_dir),
|
|
)
|
|
mock_client.get_download_path.return_value = str(source_dir)
|
|
|
|
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="dir-staging-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
|
|
staged_dir = Path(result)
|
|
assert staged_dir.is_dir()
|
|
assert (staged_dir / "book.epub").exists()
|
|
assert (staged_dir / "cover.jpg").exists()
|
|
|
|
def test_handles_duplicate_filename(self):
|
|
"""Usenet downloads return the original file path (no staging)."""
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
source_file = Path(tmp_dir) / "source" / "book.epub"
|
|
source_file.parent.mkdir(parents=True)
|
|
source_file.write_text("new content")
|
|
|
|
staging_dir = Path(tmp_dir) / "staging"
|
|
staging_dir.mkdir()
|
|
# Create existing file with same name
|
|
(staging_dir / "book.epub").write_text("old content")
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.name = "test_client"
|
|
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=str(source_file),
|
|
)
|
|
mock_client.get_download_path.return_value = str(source_file)
|
|
|
|
# Use usenet protocol - torrents skip staging and return original path directly
|
|
with (
|
|
patch(
|
|
"shelfmark.release_sources.prowlarr.handler.get_release",
|
|
return_value={
|
|
"protocol": "usenet",
|
|
"downloadUrl": "https://indexer.example.com/download/123",
|
|
},
|
|
),
|
|
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="dup-staging-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
|
|
returned_file = Path(result)
|
|
assert returned_file == source_file
|
|
assert returned_file.exists()
|
|
assert returned_file.read_text() == "new content"
|
|
|
|
|
|
class TestProwlarrHandlerPostProcessCleanup:
|
|
def test_usenet_move_triggers_client_cleanup(self):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(task_id="cleanup-test", source="prowlarr", title="Test")
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.name = "nzbget"
|
|
handler._cleanup_refs[task.task_id] = (mock_client, "123", "usenet")
|
|
|
|
with patch("shelfmark.download.clients.base_handler.config.get", return_value="move"):
|
|
handler.post_process_cleanup(task, success=True)
|
|
|
|
mock_client.remove.assert_called_once_with("123", delete_files=True)
|
|
|
|
def test_usenet_copy_does_not_cleanup(self):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(task_id="cleanup-test", source="prowlarr", title="Test")
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.name = "nzbget"
|
|
handler._cleanup_refs[task.task_id] = (mock_client, "123", "usenet")
|
|
|
|
with patch("shelfmark.download.clients.base_handler.config.get", return_value="copy"):
|
|
handler.post_process_cleanup(task, success=True)
|
|
|
|
mock_client.remove.assert_not_called()
|
|
|
|
def test_usenet_move_logs_cleanup_failure(self):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(task_id="cleanup-failure", source="prowlarr", title="Test")
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.name = "nzbget"
|
|
handler._cleanup_refs[task.task_id] = (mock_client, "123", "usenet")
|
|
handler._delete_local_download_data = MagicMock(side_effect=ConnectionError("offline"))
|
|
|
|
with (
|
|
patch("shelfmark.download.clients.base_handler.config.get", return_value="move"),
|
|
patch("shelfmark.download.clients.base_handler.logger.warning") as mock_warning,
|
|
):
|
|
handler.post_process_cleanup(task, success=True)
|
|
|
|
mock_warning.assert_called_once()
|
|
args = mock_warning.call_args.args
|
|
assert args[0] == "Failed to cleanup usenet download %s in %s: %s"
|
|
assert args[1] == "123"
|
|
assert args[2] == "nzbget"
|
|
assert str(args[3]) == "offline"
|
|
|
|
def test_torrent_remove_logs_cleanup_failure(self):
|
|
handler = ProwlarrHandler()
|
|
task = DownloadTask(task_id="torrent-cleanup-failure", source="prowlarr", title="Test")
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.name = "qbittorrent"
|
|
mock_client.remove.side_effect = ConnectionError("offline")
|
|
handler._cleanup_refs[task.task_id] = (mock_client, "123", "torrent")
|
|
|
|
with (
|
|
patch("shelfmark.download.clients.base_handler.config.get", return_value="remove"),
|
|
patch("shelfmark.download.clients.base_handler.logger.warning") as mock_warning,
|
|
):
|
|
handler.post_process_cleanup(task, success=True)
|
|
|
|
mock_warning.assert_called_once()
|
|
args = mock_warning.call_args.args
|
|
assert args[0] == "Failed to remove torrent %s from %s: %s"
|
|
assert args[1] == "123"
|
|
assert args[2] == "qbittorrent"
|
|
assert str(args[3]) == "offline"
|
|
|
|
def test_delete_local_download_data_ignores_path_lookup_failure(self):
|
|
handler = ProwlarrHandler()
|
|
mock_client = MagicMock()
|
|
mock_client.name = "nzbget"
|
|
mock_client.get_download_path.side_effect = ConnectionError("path lookup failed")
|
|
|
|
with patch("shelfmark.download.clients.base_handler.logger.debug") as mock_debug:
|
|
handler._delete_local_download_data(mock_client, "123")
|
|
|
|
mock_debug.assert_called_once()
|
|
args = mock_debug.call_args.args
|
|
assert args[0] == "Failed to resolve download path for %s %s: %s"
|
|
assert args[1] == "nzbget"
|
|
assert args[2] == "123"
|
|
assert str(args[3]) == "path lookup failed"
|
|
|
|
def test_delete_local_download_data_logs_delete_failure(self, tmp_path, monkeypatch):
|
|
import shelfmark.core.path_mappings as path_mappings
|
|
import shelfmark.download.clients.base_handler as base_handler
|
|
|
|
handler = ProwlarrHandler()
|
|
download_file = tmp_path / "downloads" / "book.epub"
|
|
download_file.parent.mkdir(parents=True)
|
|
download_file.write_text("content")
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.name = "nzbget"
|
|
mock_client.get_download_path.return_value = str(download_file)
|
|
|
|
monkeypatch.setattr(path_mappings, "get_client_host_identifier", lambda _client: "")
|
|
monkeypatch.setattr(path_mappings, "parse_remote_path_mappings", lambda _value: [])
|
|
monkeypatch.setattr(
|
|
path_mappings,
|
|
"remap_remote_to_local_with_match",
|
|
lambda *, mappings, host, remote_path: (remote_path, None),
|
|
)
|
|
|
|
def fake_run_blocking_io(func, *args, **kwargs):
|
|
name = getattr(func, "__name__", "")
|
|
if name == "exists":
|
|
return True
|
|
if name == "is_dir":
|
|
return False
|
|
if name == "unlink":
|
|
raise ConnectionError("delete failed")
|
|
return func(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(base_handler, "run_blocking_io", fake_run_blocking_io)
|
|
|
|
with patch("shelfmark.download.clients.base_handler.logger.warning") as mock_warning:
|
|
handler._delete_local_download_data(mock_client, "123")
|
|
|
|
mock_warning.assert_called_once()
|
|
args = mock_warning.call_args.args
|
|
assert args[0] == "Failed to delete local download data for %s %s: %s"
|
|
assert args[1] == "nzbget"
|
|
assert args[2] == "123"
|
|
assert str(args[3]) == "delete failed"
|
|
|
|
|
|
class TestRawReleaseMatchesTask:
|
|
"""Refreshing a stale release has to find it by whichever id form the task holds."""
|
|
|
|
RAW = {
|
|
"guid": "https://tracker.example/torrent/555",
|
|
"infoUrl": "https://tracker.example/details/555",
|
|
"indexerId": 25,
|
|
}
|
|
|
|
def test_matches_the_indexer_qualified_source_id(self):
|
|
assert ProwlarrHandler._raw_release_matches_task(
|
|
self.RAW, "25:https://tracker.example/torrent/555"
|
|
)
|
|
|
|
def test_still_matches_a_bare_guid_from_a_task_queued_before_the_change(self):
|
|
assert ProwlarrHandler._raw_release_matches_task(
|
|
self.RAW, "https://tracker.example/torrent/555"
|
|
)
|
|
|
|
def test_still_matches_a_bare_info_url(self):
|
|
assert ProwlarrHandler._raw_release_matches_task(
|
|
self.RAW, "https://tracker.example/details/555"
|
|
)
|
|
|
|
def test_does_not_match_the_same_guid_from_a_different_indexer_entry(self):
|
|
assert not ProwlarrHandler._raw_release_matches_task(
|
|
self.RAW, "10:https://tracker.example/torrent/555"
|
|
)
|
|
|
|
def test_does_not_match_an_unrelated_release(self):
|
|
assert not ProwlarrHandler._raw_release_matches_task(self.RAW, "25:something-else")
|
|
|
|
|
|
class TestRawReleaseMatchesTaskEdgeCases:
|
|
"""Matching the wrong release here grabs the wrong torrent."""
|
|
|
|
def test_blank_task_id_never_matches(self):
|
|
raw = {"guid": "g", "infoUrl": "i", "indexerId": 25}
|
|
|
|
assert not ProwlarrHandler._raw_release_matches_task(raw, "")
|
|
assert not ProwlarrHandler._raw_release_matches_task(raw, " ")
|
|
assert not ProwlarrHandler._raw_release_matches_task(raw, None)
|
|
|
|
def test_a_release_with_no_identifiers_does_not_match_a_blank_task_id(self):
|
|
assert not ProwlarrHandler._raw_release_matches_task({}, None)
|
|
assert not ProwlarrHandler._raw_release_matches_task({}, "")
|
|
|
|
def test_matches_a_qualified_id_built_from_a_guidless_release(self):
|
|
raw = {"indexerId": 25, "indexer": "MAM", "title": "Dune"}
|
|
built = build_source_id(raw)
|
|
|
|
assert ProwlarrHandler._raw_release_matches_task(raw, built)
|