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)