Fix: Prowlarr seedtime priority (#946)

This commit is contained in:
Alex
2026-05-04 14:25:05 +01:00
committed by GitHub
parent ba62771a53
commit 196578fb18
7 changed files with 262 additions and 11 deletions
+6
View File
@@ -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"))
+60 -2
View File
@@ -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()
+15 -3
View File
@@ -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,
+31 -4
View File
@@ -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,
)
@@ -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
+26
View File
@@ -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",
+87 -2
View File
@@ -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