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.
This commit is contained in:
splitsec2 authored and GitHub committed 2026-10-20 23:32:54 -04:00
1 parent 77b80eb31a
commit f2269aa201
4 files changed
+162

No files matched your search

+22
View File
@@ -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` |
<details>
@@ -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)**
@@ -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:
@@ -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)",
+98
View File
@@ -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)