From 196578fb1862ac6dcf2f86ed0b8f03e8bdfbf2a0 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Mon, 4 May 2026 14:25:05 +0100 Subject: [PATCH] Fix: Prowlarr seedtime priority (#946) --- shelfmark/download/orchestrator.py | 6 ++ shelfmark/release_sources/prowlarr/api.py | 62 ++++++++++++- shelfmark/release_sources/prowlarr/handler.py | 18 +++- shelfmark/release_sources/prowlarr/source.py | 35 +++++++- .../test_orchestrator_user_output_mode.py | 37 ++++++++ tests/prowlarr/test_handler.py | 26 ++++++ tests/prowlarr/test_source.py | 89 ++++++++++++++++++- 7 files changed, 262 insertions(+), 11 deletions(-) diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index 0631102..753a889 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -168,12 +168,18 @@ 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: + 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: + 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")) diff --git a/shelfmark/release_sources/prowlarr/api.py b/shelfmark/release_sources/prowlarr/api.py index 6f1f81f..413312a 100644 --- a/shelfmark/release_sources/prowlarr/api.py +++ b/shelfmark/release_sources/prowlarr/api.py @@ -3,7 +3,7 @@ from collections.abc import Mapping from contextlib import suppress from http import HTTPStatus -from typing import Any +from typing import Any, TypedDict import requests @@ -11,7 +11,7 @@ from shelfmark.core.logger import setup_logger from shelfmark.core.utils import normalize_http_url from shelfmark.download.network import get_ssl_verify from shelfmark.release_sources.prowlarr.torznab import parse_torznab_xml -from shelfmark.release_sources.prowlarr.utils import coerce_int_like +from shelfmark.release_sources.prowlarr.utils import coerce_float_like, coerce_int_like logger = setup_logger(__name__) @@ -27,6 +27,15 @@ _PROWLARR_CLIENT_ERRORS = ( ) +class IndexerSeedSettings(TypedDict, total=False): + ratio_limit: float + seeding_time_limit_minutes: int + + +_INDEXER_FIELD_SEED_RATIO = "torrentBaseSettings.seedRatio" +_INDEXER_FIELD_SEED_TIME_MINUTES = "torrentBaseSettings.seedTime" + + def _normalize_json_object(payload: object, *, context: str) -> dict[str, Any]: """Return a JSON object payload with string keys or raise on unexpected shapes.""" if not isinstance(payload, Mapping): @@ -52,6 +61,19 @@ def _normalize_json_object_list(payload: object, *, context: str) -> list[dict[s return [_normalize_json_object(item, context=context) for item in payload] +def _get_field_value(fields: object, name: str) -> object | None: + if not isinstance(fields, list): + return None + + for field in fields: + if not isinstance(field, Mapping): + continue + if field.get("name") == name: + return field.get("value") + + return None + + class ProwlarrClient: """Client for interacting with the Prowlarr API.""" @@ -183,6 +205,42 @@ class ProwlarrClient: return enriched_ids + def get_indexer_seed_settings( + self, *, restrict_to: list[int] | None = None + ) -> dict[int, IndexerSeedSettings]: + """Return configured per-indexer torrent share limits. + + Prowlarr exposes seedTime in minutes, which is also the unit expected by + torrent clients. + """ + settings_by_indexer: dict[int, IndexerSeedSettings] = {} + + for idx in self.get_enabled_indexers_detailed(): + idx_id_int = coerce_int_like(idx.get("id")) + if idx_id_int is None: + continue + if restrict_to is not None and idx_id_int not in restrict_to: + continue + if str(idx.get("protocol") or "").lower() != "torrent": + continue + + fields = idx.get("fields") + ratio_limit = coerce_float_like(_get_field_value(fields, _INDEXER_FIELD_SEED_RATIO)) + seeding_time_limit = coerce_int_like( + _get_field_value(fields, _INDEXER_FIELD_SEED_TIME_MINUTES) + ) + + settings: IndexerSeedSettings = {} + if ratio_limit is not None and ratio_limit > 0: + settings["ratio_limit"] = ratio_limit + if seeding_time_limit is not None and seeding_time_limit > 0: + settings["seeding_time_limit_minutes"] = seeding_time_limit + + if settings: + settings_by_indexer[idx_id_int] = settings + + return settings_by_indexer + def get_enabled_indexers(self) -> list[dict[str, Any]]: """Get enabled indexers with book capability info.""" indexers = self.get_indexers() diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index 590649c..27105d8 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -69,6 +69,13 @@ def _coerce_seed_time_minutes(raw_seed_time: object) -> int | None: 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: + return None + return minutes if minutes > 0 else None + + @register_handler("prowlarr") class ProwlarrHandler(ExternalClientHandler): """Handler for Prowlarr downloads via configured torrent or usenet client.""" @@ -157,12 +164,17 @@ 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 - # Seed criteria from the indexer (Torznab attributes) + 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 = _coerce_seed_time_minutes(raw_seed_time) - ratio_limit = float(raw_ratio) if raw_ratio is not None else None + 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 return DownloadRequest( url=download_url, diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index c655941..217c080 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -27,7 +27,7 @@ from shelfmark.release_sources import ( SortOption, register_source, ) -from shelfmark.release_sources.prowlarr.api import ProwlarrClient +from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, ProwlarrClient from shelfmark.release_sources.prowlarr.cache import cache_release from shelfmark.release_sources.prowlarr.utils import ( coerce_float_like, @@ -435,6 +435,8 @@ def _prowlarr_result_to_release( "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"), "formats": formats or None, "formats_display": formats_display, @@ -444,6 +446,27 @@ def _prowlarr_result_to_release( ) +def _apply_indexer_seed_settings( + result: dict, + indexer_seed_settings: dict[int, IndexerSeedSettings], +) -> dict: + indexer_id = _coerce_indexer_id(result.get("indexerId")) + if indexer_id is None: + return result + + seed_settings = indexer_seed_settings.get(indexer_id) + if not seed_settings: + return result + + enriched_result = dict(result) + if "ratio_limit" in seed_settings: + enriched_result["configuredRatioLimit"] = seed_settings["ratio_limit"] + if "seeding_time_limit_minutes" in seed_settings: + enriched_result["configuredSeedTimeMinutes"] = seed_settings["seeding_time_limit_minutes"] + + return enriched_result + + @register_source("prowlarr") class ProwlarrSource(ReleaseSource): """Prowlarr release source for ebooks and audiobooks.""" @@ -762,6 +785,7 @@ 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) def _check_timeout() -> None: if time.monotonic() > deadline: @@ -839,15 +863,18 @@ class ProwlarrSource(ReleaseSource): results: list[Release] = [] enriched_source_ids: set[str] = set() - for r in all_results: - idx_id = r.get("indexerId") + for raw_result in all_results: + result_with_seed_settings = _apply_indexer_seed_settings( + raw_result, indexer_seed_settings + ) + idx_id = result_with_seed_settings.get("indexerId") idx_id_int = _coerce_indexer_id(idx_id) is_enriched = bool( idx_id_int is not None and idx_id_int in enriched_indexer_ids_set ) release = _prowlarr_result_to_release( - r, + result_with_seed_settings, content_type, enable_format_detection=is_enriched, ) diff --git a/tests/download/test_orchestrator_user_output_mode.py b/tests/download/test_orchestrator_user_output_mode.py index f220de9..8e9834d 100644 --- a/tests/download/test_orchestrator_user_output_mode.py +++ b/tests/download/test_orchestrator_user_output_mode.py @@ -156,6 +156,43 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch): assert task.can_retry_without_staged_source is True +def test_queue_release_prefers_configured_seed_time_minutes_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", + "title": "Queued Prowlarr Release", + "download_url": "magnet:?xt=urn:btih:abc123", + "protocol": "torrent", + "extra": { + "configured_ratio_limit": 2, + "configured_seed_time_minutes": 7200, + "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 == 2.0 + assert task.retry_seeding_time_limit_minutes == 7200 + + 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 df5eb06..41b55ca 100644 --- a/tests/prowlarr/test_handler.py +++ b/tests/prowlarr/test_handler.py @@ -260,6 +260,32 @@ class TestProwlarrHandlerSeedCriteria: assert request.seeding_time_limit == 4320 assert request.ratio_limit == 1.0 + 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, + }, + ): + 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_rounds_seed_time_up_to_next_minute(self): with patch( "shelfmark.release_sources.prowlarr.handler.get_release", diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py index 0309e19..e24ccdf 100644 --- a/tests/prowlarr/test_source.py +++ b/tests/prowlarr/test_source.py @@ -6,6 +6,7 @@ Tests the utility functions for parsing release metadata. # Import the functions to test from shelfmark.metadata_providers import BookMetadata +from shelfmark.release_sources.prowlarr.api import ProwlarrClient from shelfmark.release_sources.prowlarr.source import ( ProwlarrSource, _detect_content_type_from_categories, @@ -210,9 +211,11 @@ class TestDetectContentType: class FakeTorznabClient: - def __init__(self): + def __init__(self, search_results=None, seed_settings=None): self.calls: list[tuple[str, object]] = [] self.queries: list[str] = [] + self.search_results = search_results or [] + self.seed_settings = seed_settings or {} def get_enabled_indexers_detailed(self): return [ @@ -241,12 +244,47 @@ class FakeTorznabClient: del indexer_id, search_type, limit, offset self.calls.append((query, categories)) self.queries.append(query) - return [] + return self.search_results def get_enriched_indexer_ids(self, restrict_to=None): del restrict_to return [] + def get_indexer_seed_settings(self, restrict_to=None): + del restrict_to + return self.seed_settings + + +class TestProwlarrIndexerSeedSettings: + def test_get_indexer_seed_settings_reads_prowlarr_minutes_field(self, monkeypatch): + client = ProwlarrClient("http://prowlarr:9696", "apikey") + monkeypatch.setattr( + client, + "get_enabled_indexers_detailed", + lambda: [ + { + "id": 13, + "protocol": "torrent", + "fields": [ + {"name": "torrentBaseSettings.seedRatio", "value": "2.5"}, + {"name": "torrentBaseSettings.seedTime", "value": "7200"}, + ], + }, + { + "id": 14, + "protocol": "usenet", + "fields": [ + {"name": "torrentBaseSettings.seedRatio", "value": "3"}, + {"name": "torrentBaseSettings.seedTime", "value": "9999"}, + ], + }, + ], + ) + + assert client.get_indexer_seed_settings() == { + 13: {"ratio_limit": 2.5, "seeding_time_limit_minutes": 7200} + } + class TestProwlarrLocalizedQueries: def test_manual_query_still_applies_content_type_categories(self, monkeypatch): @@ -309,6 +347,53 @@ class TestProwlarrLocalizedQueries: assert fake_client.calls == [("my custom", None)] + def test_search_attaches_configured_seed_time_minutes_to_release(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, + } + 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", + "minimumSeedTime": 259200, + "minimumRatio": 1, + } + ], + 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 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 + def test_search_uses_localized_titles_when_available(self, monkeypatch): import shelfmark.release_sources.prowlarr.source as prowlarr_source