From d6590be551a7b29e3fa00603703edd54581c12d9 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Fri, 8 May 2026 11:24:50 +0100 Subject: [PATCH] Add seedtime preferences toggle + clean up logic (#959) Clears up seedtime logic to use user-specified seedtime only, ignore the indexer-defaults. Adds a toggle to enable the seedtime feature, disabled by default. Fixes #955 --- shelfmark/download/orchestrator.py | 15 +--- shelfmark/release_sources/prowlarr/handler.py | 35 ++------ .../release_sources/prowlarr/settings.py | 7 ++ shelfmark/release_sources/prowlarr/source.py | 8 +- shelfmark/release_sources/prowlarr/torznab.py | 5 -- .../test_orchestrator_user_output_mode.py | 86 ++++++++++++++++++- tests/prowlarr/test_handler.py | 67 +++++++++------ tests/prowlarr/test_source.py | 54 +++++++++++- 8 files changed, 196 insertions(+), 81 deletions(-) diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index 753a889..b6dbefc 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -138,13 +138,6 @@ def _optional_positive_int(value: object) -> int | None: return parsed if parsed > 0 else None -def _seed_time_seconds_to_minutes(value: object) -> int | None: - seed_time_seconds = _optional_positive_int(value) - if seed_time_seconds is None: - return None - return (seed_time_seconds + 59) // 60 - - def _config_float(value: object, default: float) -> float: if isinstance(value, bool) or value is None: return default @@ -168,20 +161,16 @@ def _build_retry_resolution_fields( protocol = normalize_optional_text(release_data.get("protocol")) ratio_limit = _optional_number(release_data.get("ratio_limit")) - if ratio_limit is None: + if ratio_limit is None and config.get("PROWLARR_USE_SEED_PREFERENCES", False): ratio_limit = _optional_number(extra.get("configured_ratio_limit")) - if ratio_limit is None: - ratio_limit = _optional_number(extra.get("minimum_ratio")) seeding_time_limit_minutes = _optional_positive_int( release_data.get("seeding_time_limit_minutes") ) - if seeding_time_limit_minutes is None: + if seeding_time_limit_minutes is None and config.get("PROWLARR_USE_SEED_PREFERENCES", False): seeding_time_limit_minutes = _optional_positive_int( extra.get("configured_seed_time_minutes") ) - if seeding_time_limit_minutes is None: - seeding_time_limit_minutes = _seed_time_seconds_to_minutes(extra.get("minimum_seed_time")) return { "retry_download_url": normalize_optional_text(release_data.get("download_url")), diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index 27105d8..e4fb2c3 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -51,24 +51,6 @@ COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS -def _coerce_seed_time_minutes(raw_seed_time: object) -> int | None: - """Convert Prowlarr's minimum seed time from seconds to whole minutes.""" - if raw_seed_time is None: - return None - - seed_time_seconds = coerce_int_like(raw_seed_time) - if seed_time_seconds is None: - logger.warning("Invalid Prowlarr minimumSeedTime value: %r", raw_seed_time) - return None - - if seed_time_seconds < 0: - logger.warning("Ignoring negative Prowlarr minimumSeedTime value: %s", seed_time_seconds) - return None - - # Round up so we never under-seed when a tracker uses a non-minute boundary. - return (seed_time_seconds + 59) // 60 - - def _coerce_positive_minutes(raw_minutes: object) -> int | None: minutes = coerce_int_like(raw_minutes) if minutes is None: @@ -164,17 +146,14 @@ class ProwlarrHandler(ExternalClientHandler): release_name = prowlarr_result.get("title") or task.title or "Unknown" expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None - raw_configured_seed_time = prowlarr_result.get("configuredSeedTimeMinutes") - raw_configured_ratio = prowlarr_result.get("configuredRatioLimit") - raw_seed_time = prowlarr_result.get("minimumSeedTime") - raw_ratio = prowlarr_result.get("minimumRatio") + seeding_time_limit = None + ratio_limit = None + if config.get("PROWLARR_USE_SEED_PREFERENCES", False): + raw_configured_seed_time = prowlarr_result.get("configuredSeedTimeMinutes") + raw_configured_ratio = prowlarr_result.get("configuredRatioLimit") - seeding_time_limit = _coerce_positive_minutes(raw_configured_seed_time) - if seeding_time_limit is None: - seeding_time_limit = _coerce_seed_time_minutes(raw_seed_time) - - ratio_source = raw_configured_ratio if raw_configured_ratio is not None else raw_ratio - ratio_limit = float(ratio_source) if ratio_source is not None else None + seeding_time_limit = _coerce_positive_minutes(raw_configured_seed_time) + ratio_limit = float(raw_configured_ratio) if raw_configured_ratio is not None else None return DownloadRequest( url=download_url, diff --git a/shelfmark/release_sources/prowlarr/settings.py b/shelfmark/release_sources/prowlarr/settings.py index 936c5df..f7feead 100644 --- a/shelfmark/release_sources/prowlarr/settings.py +++ b/shelfmark/release_sources/prowlarr/settings.py @@ -190,4 +190,11 @@ def prowlarr_config_settings() -> list[SettingsField]: description="Automatically retry search without category filtering if no results are found", show_when={"field": "PROWLARR_ENABLED", "value": True}, ), + CheckboxField( + key="PROWLARR_USE_SEED_PREFERENCES", + label="Use Prowlarr seed preferences", + default=False, + description="Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client", + show_when={"field": "PROWLARR_ENABLED", "value": True}, + ), ] diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index 217c080..154e31b 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -433,8 +433,6 @@ def _prowlarr_result_to_release( "freeleech": is_freeleech, "download_volume_factor": result.get("downloadVolumeFactor"), "upload_volume_factor": result.get("uploadVolumeFactor"), - "minimum_ratio": result.get("minimumRatio"), - "minimum_seed_time": result.get("minimumSeedTime"), "configured_ratio_limit": result.get("configuredRatioLimit"), "configured_seed_time_minutes": result.get("configuredSeedTimeMinutes"), "info_hash": result.get("infoHash"), @@ -785,7 +783,11 @@ class ProwlarrSource(ReleaseSource): # Some indexers benefit from title+author queries and extra format detection. enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids) enriched_indexer_ids_set = set(enriched_indexer_ids) - indexer_seed_settings = client.get_indexer_seed_settings(restrict_to=indexer_ids) + indexer_seed_settings = ( + client.get_indexer_seed_settings(restrict_to=indexer_ids) + if config.get("PROWLARR_USE_SEED_PREFERENCES", False) + else {} + ) def _check_timeout() -> None: if time.monotonic() > deadline: diff --git a/shelfmark/release_sources/prowlarr/torznab.py b/shelfmark/release_sources/prowlarr/torznab.py index 9d9931e..f354bb8 100644 --- a/shelfmark/release_sources/prowlarr/torznab.py +++ b/shelfmark/release_sources/prowlarr/torznab.py @@ -140,9 +140,6 @@ def parse_torznab_xml(xml_text: str) -> list[dict[str, Any]]: download_volume_factor = _coerce_float(attrs.get("downloadvolumefactor")) upload_volume_factor = _coerce_float(attrs.get("uploadvolumefactor")) - minimum_ratio = _coerce_float(attrs.get("minimumratio")) - minimum_seed_time = _coerce_int(attrs.get("minimumseedtime")) - cleaned_title = _strip_author_from_title(title, author) results.append( @@ -168,8 +165,6 @@ def parse_torznab_xml(xml_text: str) -> list[dict[str, Any]]: "bookTitle": book_title, "downloadVolumeFactor": download_volume_factor, "uploadVolumeFactor": upload_volume_factor, - "minimumRatio": minimum_ratio, - "minimumSeedTime": minimum_seed_time, # Pass through all torznab attributes for tooltip display "torznabAttrs": attrs, } diff --git a/tests/download/test_orchestrator_user_output_mode.py b/tests/download/test_orchestrator_user_output_mode.py index 8e9834d..b28b884 100644 --- a/tests/download/test_orchestrator_user_output_mode.py +++ b/tests/download/test_orchestrator_user_output_mode.py @@ -3,6 +3,16 @@ from unittest.mock import MagicMock from shelfmark.core.models import SearchMode +def enable_prowlarr_seed_preferences(monkeypatch, orchestrator): + monkeypatch.setattr( + orchestrator.config, + "get", + lambda key, default=None, user_id=None: ( + True if key == "PROWLARR_USE_SEED_PREFERENCES" else default + ), + ) + + def test_queue_release_uses_user_specific_books_output_mode(monkeypatch): import shelfmark.download.orchestrator as orchestrator @@ -125,6 +135,7 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch): monkeypatch.setattr(orchestrator.book_queue, "add", fake_add) monkeypatch.setattr(orchestrator, "ws_manager", None) + enable_prowlarr_seed_preferences(monkeypatch, orchestrator) success, error = orchestrator.queue_release( { @@ -135,8 +146,8 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch): "protocol": "torrent", "indexer": "MyIndexer", "extra": { - "minimum_ratio": 1.25, - "minimum_seed_time": 5400, + "configured_ratio_limit": 1.25, + "configured_seed_time_minutes": 90, "info_hash": "ABC123", }, }, @@ -167,6 +178,7 @@ def test_queue_release_prefers_configured_seed_time_minutes_for_retry(monkeypatc monkeypatch.setattr(orchestrator.book_queue, "add", fake_add) monkeypatch.setattr(orchestrator, "ws_manager", None) + enable_prowlarr_seed_preferences(monkeypatch, orchestrator) success, error = orchestrator.queue_release( { @@ -193,6 +205,76 @@ def test_queue_release_prefers_configured_seed_time_minutes_for_retry(monkeypatc assert task.retry_seeding_time_limit_minutes == 7200 +def test_queue_release_ignores_configured_seed_time_when_disabled_for_retry(monkeypatch): + import shelfmark.download.orchestrator as orchestrator + + captured: dict[str, object] = {} + + def fake_add(task): + captured["task"] = task + return True + + monkeypatch.setattr(orchestrator.book_queue, "add", fake_add) + monkeypatch.setattr(orchestrator, "ws_manager", None) + + success, error = orchestrator.queue_release( + { + "source": "prowlarr", + "source_id": "prowlarr-release-configured-seed-time-disabled", + "title": "Queued Prowlarr Release", + "download_url": "magnet:?xt=urn:btih:abc123", + "protocol": "torrent", + "extra": { + "configured_ratio_limit": 2, + "configured_seed_time_minutes": 7200, + }, + }, + user_id=42, + username="alice", + ) + + assert success is True + assert error is None + task = captured["task"] + assert task.retry_ratio_limit is None + assert task.retry_seeding_time_limit_minutes is None + + +def test_queue_release_ignores_torznab_minimum_seed_criteria_for_retry(monkeypatch): + import shelfmark.download.orchestrator as orchestrator + + captured: dict[str, object] = {} + + def fake_add(task): + captured["task"] = task + return True + + monkeypatch.setattr(orchestrator.book_queue, "add", fake_add) + monkeypatch.setattr(orchestrator, "ws_manager", None) + + success, error = orchestrator.queue_release( + { + "source": "prowlarr", + "source_id": "prowlarr-release-minimum-only", + "title": "Queued Prowlarr Release", + "download_url": "magnet:?xt=urn:btih:abc123", + "protocol": "torrent", + "extra": { + "minimum_ratio": 1, + "minimum_seed_time": 259200, + }, + }, + user_id=42, + username="alice", + ) + + assert success is True + assert error is None + task = captured["task"] + assert task.retry_ratio_limit is None + assert task.retry_seeding_time_limit_minutes is None + + def test_queue_release_returns_error_for_operational_queue_failure(monkeypatch): import shelfmark.download.orchestrator as orchestrator diff --git a/tests/prowlarr/test_handler.py b/tests/prowlarr/test_handler.py index 41b55ca..487a026 100644 --- a/tests/prowlarr/test_handler.py +++ b/tests/prowlarr/test_handler.py @@ -236,7 +236,7 @@ class TestProwlarrHandlerDownloadErrors: class TestProwlarrHandlerSeedCriteria: """Tests for seed criteria passed through from Prowlarr.""" - def test_resolve_download_converts_seed_time_seconds_to_minutes(self): + def test_resolve_download_ignores_torznab_minimum_seed_criteria(self): with patch( "shelfmark.release_sources.prowlarr.handler.get_release", return_value={ @@ -257,21 +257,24 @@ class TestProwlarrHandlerSeedCriteria: request = handler._resolve_download(task, lambda *_: None) assert request is not None - assert request.seeding_time_limit == 4320 - assert request.ratio_limit == 1.0 + assert request.seeding_time_limit is None + assert request.ratio_limit is None - def test_resolve_download_prefers_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, - }, + 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( @@ -286,19 +289,23 @@ class TestProwlarrHandlerSeedCriteria: assert request.seeding_time_limit == 7200 assert request.ratio_limit == 2.0 - def test_resolve_download_rounds_seed_time_up_to_next_minute(self): - with patch( - "shelfmark.release_sources.prowlarr.handler.get_release", - return_value={ - "protocol": "torrent", - "title": "Test Release", - "magnetUrl": "magnet:?xt=urn:btih:abc123", - "minimumSeedTime": 61, - }, + 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="seed-time-round-up", + task_id="configured-seed-time-disabled", source="prowlarr", title="Test Book", ) @@ -306,7 +313,8 @@ class TestProwlarrHandlerSeedCriteria: request = handler._resolve_download(task, lambda *_: None) assert request is not None - assert request.seeding_time_limit == 2 + assert request.seeding_time_limit is None + assert request.ratio_limit is None def test_download_passes_seed_limits_to_client(self): mock_client = MagicMock() @@ -321,8 +329,10 @@ class TestProwlarrHandlerSeedCriteria: "protocol": "torrent", "title": "Test Release", "magnetUrl": "magnet:?xt=urn:btih:abc123", + "configuredSeedTimeMinutes": 7200, + "configuredRatioLimit": 1.25, "minimumSeedTime": 259200, - "minimumRatio": 1.25, + "minimumRatio": 1, }, ), patch( @@ -332,6 +342,7 @@ class TestProwlarrHandlerSeedCriteria: 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", @@ -355,7 +366,7 @@ class TestProwlarrHandlerSeedCriteria: ) call_kwargs = mock_client.add_download.call_args.kwargs - assert call_kwargs["seeding_time_limit"] == 4320 + assert call_kwargs["seeding_time_limit"] == 7200 assert call_kwargs["ratio_limit"] == 1.25 diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py index e24ccdf..cba7f0d 100644 --- a/tests/prowlarr/test_source.py +++ b/tests/prowlarr/test_source.py @@ -214,6 +214,7 @@ class FakeTorznabClient: def __init__(self, search_results=None, seed_settings=None): self.calls: list[tuple[str, object]] = [] self.queries: list[str] = [] + self.seed_settings_calls: list[object] = [] self.search_results = search_results or [] self.seed_settings = seed_settings or {} @@ -251,7 +252,7 @@ class FakeTorznabClient: return [] def get_indexer_seed_settings(self, restrict_to=None): - del restrict_to + self.seed_settings_calls.append(restrict_to) return self.seed_settings @@ -354,6 +355,7 @@ class TestProwlarrLocalizedQueries: values = { "PROWLARR_INDEXERS": "", "PROWLARR_AUTO_EXPAND": False, + "PROWLARR_USE_SEED_PREFERENCES": True, } return values.get(key, default) @@ -390,9 +392,57 @@ class TestProwlarrLocalizedQueries: releases = source.search(book, plan, content_type="ebook") assert len(releases) == 1 + assert fake_client.seed_settings_calls == [None] assert releases[0].extra["configured_ratio_limit"] == 2.0 assert releases[0].extra["configured_seed_time_minutes"] == 7200 - assert releases[0].extra["minimum_seed_time"] == 259200 + assert "minimum_seed_time" not in releases[0].extra + assert "minimum_ratio" not in releases[0].extra + + def test_search_ignores_configured_seed_time_when_disabled(self, monkeypatch): + import shelfmark.release_sources.prowlarr.source as prowlarr_source + + def fake_get(key: str, default=None): + values = { + "PROWLARR_INDEXERS": "", + "PROWLARR_AUTO_EXPAND": False, + "PROWLARR_USE_SEED_PREFERENCES": False, + } + return values.get(key, default) + + monkeypatch.setattr(prowlarr_source.config, "get", fake_get) + + fake_client = FakeTorznabClient( + search_results=[ + { + "guid": "mam-result-1", + "protocol": "torrent", + "title": "Test Release", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + "indexerId": 1, + "indexer": "MyAnonamouse", + } + ], + seed_settings={1: {"ratio_limit": 2.0, "seeding_time_limit_minutes": 7200}}, + ) + source = ProwlarrSource() + monkeypatch.setattr(source, "_get_client", lambda: fake_client) + + book = BookMetadata( + provider="hardcover", + provider_id="123", + title="Anything", + authors=["Someone"], + ) + + from shelfmark.core.search_plan import build_release_search_plan + + plan = build_release_search_plan(book, languages=["en"]) + releases = source.search(book, plan, content_type="ebook") + + assert len(releases) == 1 + assert fake_client.seed_settings_calls == [] + assert releases[0].extra["configured_ratio_limit"] is None + assert releases[0].extra["configured_seed_time_minutes"] is None def test_search_uses_localized_titles_when_available(self, monkeypatch): import shelfmark.release_sources.prowlarr.source as prowlarr_source