From f2269aa201d22b6997ae5c1568c53ada7fee34d7 Mon Sep 17 00:00:00 2001 From: splitsec2 <35583321+splitsec2@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:32:54 -0600 Subject: [PATCH] feat(audiobookbay): seed time and ratio settings (#1503) Closes #1037. Two new AudiobookBay settings, Seed Time (minutes) and Seed Ratio. Both default to 0, which leaves the torrent client's own limit as it is today, so nothing changes until someone sets them. They go to the client with the magnet the same way the Prowlarr seed preferences do (`DownloadRequest.seeding_time_limit` and `ratio_limit`), so they apply with the clients that already take per-torrent limits there: qBittorrent, Transmission and Deluge. A retry that reuses the saved magnet carries them too. A value that isn't a positive number (text, negative, NaN, inf) is ignored and the client's limit stays. Tests cover reading both settings (unset, zero, each on its own, text values, bad values) and that both the fresh and the cached-magnet request carry them. Ruff, basedpyright and the backend suite pass. I haven't tried it against a live client, since the client side is the code Prowlarr already uses. I added the two entries to `docs/environment-variables.md` by hand. Running `scripts/generate_env_docs.py` here also changes unrelated parts of that file (the Soulseek table of contents link and the `SHELFMARK_API_KEY_READONLY` entry), so I left those alone. --- docs/environment-variables.md | 22 +++++ .../release_sources/audiobookbay/handler.py | 24 +++++ .../release_sources/audiobookbay/settings.py | 18 ++++ tests/audiobookbay/test_seed_limits.py | 98 +++++++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 tests/audiobookbay/test_seed_limits.py diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 6daa6635..1b202ce4 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -1642,6 +1642,8 @@ Clear the transfer from slskd's download list after Shelfmark imports it. Applie | `ABB_HOSTNAME` | AudiobookBay domain (e.g., audiobookbay.lu, audiobookbay.is). Required to enable searches. | string | _empty string_ | | `ABB_PAGE_LIMIT` | Maximum number of search result pages to fetch (1-10). | number | `1` | | `ABB_EXACT_PHRASE` | Wrap generated queries in quotes for stricter matching. If no results are found, Shelfmark retries without quotes. | boolean | `false` | +| `ABB_SEED_TIME_MINUTES` | How long a finished AudiobookBay torrent keeps seeding before the client stops it. 0 uses the torrent client's own limit. | number | `0` | +| `ABB_SEED_RATIO` | Share ratio an AudiobookBay torrent should reach before the client stops it. 0 uses the torrent client's own limit. | number | `0.0` | | `ABB_RATE_LIMIT_DELAY` | Delay between requests in seconds to avoid rate limiting (0-10). | number | `1.0` |
@@ -1685,6 +1687,26 @@ Wrap generated queries in quotes for stricter matching. If no results are found, - **Type:** boolean - **Default:** `false` +#### `ABB_SEED_TIME_MINUTES` + +**Seed Time (minutes)** + +How long a finished AudiobookBay torrent keeps seeding before the client stops it. 0 uses the torrent client's own limit. + +- **Type:** number +- **Default:** `0` +- **Constraints:** min: 0, max: 525600 + +#### `ABB_SEED_RATIO` + +**Seed Ratio** + +Share ratio an AudiobookBay torrent should reach before the client stops it. 0 uses the torrent client's own limit. + +- **Type:** number +- **Default:** `0.0` +- **Constraints:** min: 0.0, max: 100.0 + #### `ABB_RATE_LIMIT_DELAY` **Rate Limit Delay (seconds)** diff --git a/shelfmark/release_sources/audiobookbay/handler.py b/shelfmark/release_sources/audiobookbay/handler.py index a689cb7d..c3a256ae 100644 --- a/shelfmark/release_sources/audiobookbay/handler.py +++ b/shelfmark/release_sources/audiobookbay/handler.py @@ -1,10 +1,12 @@ """AudiobookBay download handler - resolves magnet links and uses shared client lifecycle.""" +import math from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from shelfmark.core.config import config from shelfmark.core.logger import setup_logger +from shelfmark.core.request_helpers import coerce_int from shelfmark.download.clients import ( DownloadClient, get_client, @@ -56,6 +58,22 @@ def _detail_url_matches_host(detail_url: str, hostname: str) -> bool: ) +def _seed_limits() -> tuple[int | None, float | None]: + """The configured seed time (minutes) and share ratio; None leaves the client's own limit.""" + minutes = coerce_int(config.get("ABB_SEED_TIME_MINUTES", 0), 0) + raw_ratio = config.get("ABB_SEED_RATIO", 0) + ratio = 0.0 + if isinstance(raw_ratio, (int, float, str)): + try: + ratio = float(raw_ratio) + except ValueError: + ratio = 0.0 + return ( + minutes if minutes > 0 else None, + ratio if math.isfinite(ratio) and ratio > 0 else None, + ) + + @register_handler("audiobookbay") class AudiobookBayHandler(ExternalClientHandler): """Handler for AudiobookBay downloads via configured torrent client.""" @@ -105,11 +123,14 @@ class AudiobookBayHandler(ExternalClientHandler): cached_magnet = task.retry_source_context.get(MAGNET_LINK_CONTEXT_KEY) if isinstance(cached_magnet, str) and cached_magnet: logger.info("Reusing magnet link from an earlier attempt for task %s", task.task_id) + seeding_time_limit, ratio_limit = _seed_limits() return DownloadRequest( url=cached_magnet, protocol="torrent", release_name=task.title or "Unknown", expected_hash=None, + seeding_time_limit=seeding_time_limit, + ratio_limit=ratio_limit, ) detail_url = self._resolve_detail_url(task) @@ -137,11 +158,14 @@ class AudiobookBayHandler(ExternalClientHandler): logger.info("Extracted magnet link for task %s", task.task_id) task.retry_source_context[MAGNET_LINK_CONTEXT_KEY] = magnet_link + seeding_time_limit, ratio_limit = _seed_limits() return DownloadRequest( url=magnet_link, protocol="torrent", release_name=task.title or "Unknown", expected_hash=None, + seeding_time_limit=seeding_time_limit, + ratio_limit=ratio_limit, ) def cancel(self, task_id: str) -> bool: diff --git a/shelfmark/release_sources/audiobookbay/settings.py b/shelfmark/release_sources/audiobookbay/settings.py index 55f5297b..075471d5 100644 --- a/shelfmark/release_sources/audiobookbay/settings.py +++ b/shelfmark/release_sources/audiobookbay/settings.py @@ -44,6 +44,24 @@ def audiobookbay_config_settings() -> list[SettingsField]: description="Wrap generated queries in quotes for stricter matching. If no results are found, Shelfmark retries without quotes.", show_when={"field": "ABB_ENABLED", "value": True}, ), + NumberField( + key="ABB_SEED_TIME_MINUTES", + label="Seed Time (minutes)", + description="How long a finished AudiobookBay torrent keeps seeding before the client stops it. 0 uses the torrent client's own limit.", + default=0, + min_value=0, + max_value=525600, + show_when={"field": "ABB_ENABLED", "value": True}, + ), + NumberField( + key="ABB_SEED_RATIO", + label="Seed Ratio", + description="Share ratio an AudiobookBay torrent should reach before the client stops it. 0 uses the torrent client's own limit.", + default=0.0, + min_value=0.0, + max_value=100.0, + show_when={"field": "ABB_ENABLED", "value": True}, + ), NumberField( key="ABB_RATE_LIMIT_DELAY", label="Rate Limit Delay (seconds)", diff --git a/tests/audiobookbay/test_seed_limits.py b/tests/audiobookbay/test_seed_limits.py new file mode 100644 index 00000000..f703fd8d --- /dev/null +++ b/tests/audiobookbay/test_seed_limits.py @@ -0,0 +1,98 @@ +"""Seed time and ratio for AudiobookBay torrents are handed to the torrent client.""" + +from unittest.mock import patch + +import pytest + +from shelfmark.core.models import DownloadTask +from shelfmark.release_sources.audiobookbay.handler import AudiobookBayHandler, _seed_limits + +MAGNET = "magnet:?xt=urn:btih:abc123" + + +def _config(**values): + settings = {"ABB_HOSTNAME": "audiobookbay.lu", **values} + + def _get(key, default=None, user_id=None): + del user_id + return settings.get(key, default) + + return patch("shelfmark.release_sources.audiobookbay.handler.config.get", side_effect=_get) + + +def _task(**kwargs): + return DownloadTask( + task_id="https://audiobookbay.lu/abss/test-book/", + source="audiobookbay", + title="Test Book", + content_type="audiobook", + **kwargs, + ) + + +def _resolve(task): + return AudiobookBayHandler()._resolve_download(task, lambda status, message=None: None) + + +class TestSeedLimits: + def test_nothing_set_leaves_the_clients_own_limits(self): + with _config(): + assert _seed_limits() == (None, None) + + def test_zero_means_the_clients_own_limit(self): + with _config(ABB_SEED_TIME_MINUTES=0, ABB_SEED_RATIO=0.0): + assert _seed_limits() == (None, None) + + def test_both_are_read(self): + with _config(ABB_SEED_TIME_MINUTES=1440, ABB_SEED_RATIO=1.5): + assert _seed_limits() == (1440, 1.5) + + def test_the_two_are_independent(self): + with _config(ABB_SEED_TIME_MINUTES=60): + assert _seed_limits() == (60, None) + with _config(ABB_SEED_RATIO=2): + assert _seed_limits() == (None, 2.0) + + @pytest.mark.parametrize("value", ["soon", None, [], {}, float("nan"), float("inf"), -1, "-2"]) + def test_a_bad_ratio_is_ignored(self, value): + with _config(ABB_SEED_RATIO=value): + assert _seed_limits()[1] is None + + @pytest.mark.parametrize("value", ["soon", None, [], -5]) + def test_a_bad_seed_time_is_ignored(self, value): + with _config(ABB_SEED_TIME_MINUTES=value): + assert _seed_limits()[0] is None + + def test_a_ratio_given_as_text_is_read(self): + with _config(ABB_SEED_RATIO="1.25"): + assert _seed_limits()[1] == 1.25 + + +class TestRequest: + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + def test_a_new_download_carries_the_limits(self, mock_extract): + mock_extract.return_value = MAGNET + with _config(ABB_SEED_TIME_MINUTES=720, ABB_SEED_RATIO=2.0): + request = _resolve(_task()) + + assert request is not None + assert request.seeding_time_limit == 720 + assert request.ratio_limit == 2.0 + + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + def test_a_new_download_without_limits_carries_none(self, mock_extract): + mock_extract.return_value = MAGNET + with _config(): + request = _resolve(_task()) + + assert request is not None + assert (request.seeding_time_limit, request.ratio_limit) == (None, None) + + def test_a_retry_from_the_cached_magnet_carries_the_limits_too(self): + task = _task(retry_source_context={"magnet_link": MAGNET}) + with _config(ABB_SEED_TIME_MINUTES=720, ABB_SEED_RATIO=2.0): + request = _resolve(task) + + assert request is not None + assert request.url == MAGNET + assert (request.seeding_time_limit, request.ratio_limit) == (720, 2.0)