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
This commit is contained in:
Alex
2026-05-08 11:24:50 +01:00
committed by GitHub
parent 4c782ca92d
commit d6590be551
8 changed files with 196 additions and 81 deletions
+2 -13
View File
@@ -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")),
+7 -28
View File
@@ -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,
@@ -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},
),
]
+5 -3
View File
@@ -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:
@@ -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,
}
@@ -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
+39 -28
View File
@@ -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
+52 -2
View File
@@ -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