mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 10:11:13 +01:00
Fix: Prowlarr seedtime priority (#946)
This commit is contained in:
@@ -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"))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user