mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 19:20:30 +01:00
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:
@@ -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")),
|
||||
|
||||
@@ -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},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user