feat(clients): give up on a torrent with no seeders (#1494)

A torrent with no seeds won't finish. It runs on the pieces its few
peers hold and then sits still, and nothing says so until the stall
timer ends it. The card only reads "downloading". I run AudiobookBay
releases, which show no seed count in the listing, so the client is the
only place to find out.

Two new settings, both off until you turn them on:
`TORRENT_NO_SEEDERS_WAIT` (minutes, 0 turns it off) and
`TORRENT_MIN_SEEDERS` (default 1).

- A torrent that has fewer seeds than the minimum for the whole wait,
while nothing is downloading, is removed from the client with its data
and the task ends as an error.
- A torrent with no seed at all for the wait is given up on even while
it still moves.
- A torrent that met the minimum and lost seeds down to one, but is
still moving, keeps going.
- The card shows the seed count beside the progress, with a countdown
while it waits ("0 seeds, giving up in 4m").

qBittorrent only, because it reports connected and tracker seed counts.
Other clients report none and are left to the stall timer, and an
unknown count is not read as zero. A torrent that was already in the
client when the download started is never removed, and a queued torrent
isn't judged.

Tests cover the wait and countdown, the clock restarting on progress,
the tracker count, a higher minimum, off by default, a client with no
counts, a preexisting torrent, a queued torrent, and the moving cases.
Ruff and the backend suite pass.
This commit is contained in:
splitsec2 authored and GitHub committed 2026-10-20 23:26:13 -04:00
1 parent 9ba5816b83
commit e83bab9337
6 files changed
+409 -1

No files matched your search

+22
View File
@@ -1870,6 +1870,8 @@ How long to keep cached search results before they expire.
| `RTORRENT_AUDIOBOOK_LABEL` | Label to assign to audiobook downloads in rTorrent (falls back to Book Label if not set) | string | _none_ |
| `RTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
| `PROWLARR_TORRENT_ACTION` | After a successful import, Remove keeps downloaded files in qBittorrent, Transmission, and Deluge; Remove & Delete Files also deletes them. rTorrent cannot delete download data, so this action leaves its torrent untouched. Blackhole does not support removal. Debrid clients delete temporary local files for either Remove action. | string (choice) | `keep` |
| `TORRENT_NO_SEEDERS_WAIT` | Give up on a torrent when its client sees no seed at all for this long, even while it is still downloading, or fewer seeds than the minimum below while nothing is downloading. The torrent is removed and the download marked failed, so another release can be tried. The card shows the seed count and a countdown while it waits. 0 keeps the default behaviour (only the stall timer ends it). Only qBittorrent reports seed counts. | number | `0` |
| `TORRENT_MIN_SEEDERS` | Seeds the client must see for a motionless torrent to keep waiting. 0 also turns the no-seed check off. | number | `1` |
| `PROWLARR_TORRENT_POST_IMPORT_CATEGORY` | Category or label to assign after a successful import | string | _empty string_ |
| `TORRENT_STALL_TIMEOUT_MINUTES` | How long a torrent can go without any progress before Shelfmark cancels it. Applies from when the torrent starts and restarts each time it moves. Raise it for slow swarms or magnets that take a while to find peers, or lower it to give up on dead torrents sooner. | number | `15` |
| `PROWLARR_USENET_CLIENT` | Choose which usenet client to use | string (choice) | _empty string_ |
@@ -2186,6 +2188,26 @@ After a successful import, Remove keeps downloaded files in qBittorrent, Transmi
- **Default:** `keep`
- **Options:** `keep` (Keep), `remove` (Remove), `remove_and_delete` (Remove & Delete Files), `change_category` (Change Category)
#### `TORRENT_NO_SEEDERS_WAIT`
**Give Up With No Seeders (minutes)**
Give up on a torrent when its client sees no seed at all for this long, even while it is still downloading, or fewer seeds than the minimum below while nothing is downloading. The torrent is removed and the download marked failed, so another release can be tried. The card shows the seed count and a countdown while it waits. 0 keeps the default behaviour (only the stall timer ends it). Only qBittorrent reports seed counts.
- **Type:** number
- **Default:** `0`
- **Constraints:** min: 0, max: 240
#### `TORRENT_MIN_SEEDERS`
**Minimum Seeders**
Seeds the client must see for a motionless torrent to keep waiting. 0 also turns the no-seed check off.
- **Type:** number
- **Default:** `1`
- **Constraints:** min: 0, max: 1000
#### `PROWLARR_TORRENT_POST_IMPORT_CATEGORY`
**Post-Import Category**
+3
View File
@@ -135,6 +135,9 @@ class DownloadStatus:
file_path: str | None # Path in client's download dir (when complete)
download_speed: int | None = None # Bytes per second
eta: int | None = None # Seconds remaining
seeders: int | None = None # Seeds connected right now (torrent clients that report it)
swarm_seeders: int | None = None # Seeds the tracker says exist, connected or not
peers: int | None = None # Other peers connected right now
@classmethod
def error(cls, message: str) -> DownloadStatus:
+114 -1
View File
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Protocol, TypeGuard
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import normalize_optional_text
from shelfmark.core.request_helpers import coerce_int, normalize_optional_text
from shelfmark.core.utils import is_audiobook
from shelfmark.download.activity import release_activity_grace, request_activity_grace
from shelfmark.download.clients import (
@@ -71,6 +71,12 @@ QUEUE_MAX_WAIT_SECONDS = 7200.0
# time a torrent may sit without progress before it is cancelled, given once when the
# torrent starts and restarted every time its progress goes up. The default is 15 minutes;
# five is the shortest, which is the orchestrator's own timeout and so adds nothing.
# A torrent that is not moving and has fewer seeds than this is given up on after the wait
# below (TORRENT_NO_SEEDERS_WAIT, minutes, 0 turns it off). The client's own seed counts decide.
NO_SEEDERS_WAIT_SETTING = "TORRENT_NO_SEEDERS_WAIT"
MIN_SEEDERS_SETTING = "TORRENT_MIN_SEEDERS"
NO_SEEDERS_WAIT_DEFAULT_MINUTES = 0.0
MIN_SEEDERS_DEFAULT = 1
WINDOWS_DRIVE_PREFIX_LENGTH = 2
SECONDS_PER_MINUTE = 60
SECONDS_PER_HOUR = 3600
@@ -280,6 +286,27 @@ class ExternalClientHandler(DownloadHandler, ABC):
"""Return the polling interval for status checks."""
return POLL_INTERVAL
def _no_seeders_wait_seconds(self) -> float:
"""How long a motionless torrent with too few seeds is kept (seconds, 0 = never give up)."""
configured = config.get(NO_SEEDERS_WAIT_SETTING, NO_SEEDERS_WAIT_DEFAULT_MINUTES)
if not isinstance(configured, (int, float, str)):
return 0.0
try:
minutes = float(configured)
except ValueError:
return 0.0
return minutes * SECONDS_PER_MINUTE if minutes > 0 else 0.0
def _min_seeders(self) -> int:
configured = config.get(MIN_SEEDERS_SETTING, MIN_SEEDERS_DEFAULT)
return max(coerce_int(configured, MIN_SEEDERS_DEFAULT), 0)
@staticmethod
def _seed_count(status: DownloadStatus) -> int | None:
"""The most seeds the client can see, connected or reported by the tracker; None if it says nothing."""
counts = [c for c in (status.seeders, status.swarm_seeders) if c is not None]
return max(counts) if counts else None
def _completed_path_retry_interval(self) -> float:
"""Retry interval while waiting for completed files (seconds)."""
return COMPLETED_PATH_RETRY_INTERVAL
@@ -565,6 +592,21 @@ class ExternalClientHandler(DownloadHandler, ABC):
)
status_callback("cancelled", "Cancelled")
def _remove_dead_torrent(self, client: DownloadClient, download_id: str) -> bool:
"""Remove a torrent Shelfmark added and has given up on for want of seeders, with its data.
Unlike a cancel, it is removed at any progress: with no seed it cannot finish, and
left in the client it would hold a slot and a partial copy for a release that is
remembered as failed.
"""
try:
return bool(client.remove(download_id, delete_files=True))
except _CLIENT_CLEANUP_ERRORS as e:
logger.warning(
"Could not remove dead torrent %s from %s: %s", download_id, client.name, e
)
return False
def _remove_unstarted_torrent(self, client: DownloadClient, download_id: str) -> bool:
"""Remove a torrent that downloaded nothing; True if it was removed.
@@ -872,6 +914,17 @@ class ExternalClientHandler(DownloadHandler, ABC):
return None, last_error
@staticmethod
def _seed_note(seeds: int | None, give_up_in: float | None) -> str:
"""What the card shows beside progress so nobody has to open the client to see the swarm."""
if seeds is None:
return ""
note = f" · {seeds} seed{'s' if seeds != 1 else ''}"
if give_up_in is not None and give_up_in > 0:
minutes_left = max(math.ceil(give_up_in / SECONDS_PER_MINUTE), 1)
note += f", giving up in {minutes_left}m"
return note
def _build_progress_message(self, status: DownloadStatus) -> str:
"""Build a progress message from download status."""
msg = f"{status.progress:.0f}%"
@@ -1085,6 +1138,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
if protocol == "torrent"
else float(STALL_TIMEOUT_MIN_MINUTES * SECONDS_PER_MINUTE)
)
no_seeders_since: float | None = None
zero_seeds_since: float | None = None
# Track consecutive "not found" errors - torrents may take time to appear in client
not_found_count = 0
max_not_found_retries = 15 # 15 retries * poll interval ~= 30s grace period
@@ -1189,6 +1244,63 @@ class ExternalClientHandler(DownloadHandler, ABC):
# timeout, so the torrent needs a fresh window even if it had one before.
start_window_given = False
no_seeders_left: float | None = None
no_seeders_wait = self._no_seeders_wait_seconds()
min_seeders = self._min_seeders()
seeds = self._seed_count(status)
judged = (
no_seeders_wait > 0
and min_seeders > 0
and status.state == DownloadState.DOWNLOADING
and seeds is not None
)
# Two ways to be given up on, each with its own clock. Too few seeds and not
# moving; or no seed at all, moving or not: a torrent running on the pieces
# its peers happen to hold stops when they run out.
too_few_idle = (
judged
and seeds is not None
and seeds < min_seeders
and status.progress <= last_progress
)
no_seed_at_all = judged and seeds == 0
now = time.monotonic() if too_few_idle or no_seed_at_all else 0.0
if too_few_idle:
if no_seeders_since is None:
no_seeders_since = now
else:
no_seeders_since = None
if no_seed_at_all:
if zero_seeds_since is None:
zero_seeds_since = now
else:
zero_seeds_since = None
lefts = [
no_seeders_wait - (now - since)
for since in (no_seeders_since, zero_seeds_since)
if since is not None
]
if lefts:
no_seeders_left = min(lefts)
if no_seeders_left <= 0:
minutes = no_seeders_wait / SECONDS_PER_MINUTE
logger.warning(
"Giving up on %s (%s): %s seeders found, %s needed, for %.0f min",
client.name,
download_id,
seeds,
min_seeders,
minutes,
)
if added_by_shelfmark:
self._remove_dead_torrent(client, download_id)
status_callback(
"error",
f"No seeders: {seeds} found, {min_seeders} needed, "
f"for {minutes:g} min ({status.progress:.0f}% downloaded)",
)
return None
if status.progress > last_progress:
if stall_window > STALL_TIMEOUT_MIN_MINUTES * SECONDS_PER_MINUTE:
request_activity_grace(status_callback, stall_window)
@@ -1203,6 +1315,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
# Build status message - use client message if provided, else build progress
msg = status.message or self._build_progress_message(status)
msg += self._seed_note(seeds, no_seeders_left)
if status.state == DownloadState.PROCESSING:
# Post-processing (e.g., SABnzbd verifying/extracting)
status_callback("resolving", msg)
+14
View File
@@ -70,6 +70,13 @@ _INCOMPLETE_STATES = frozenset(
)
def _non_negative_int(value: object) -> int | None:
"""A client count, or None when it is missing or the client's "unknown" (-1)."""
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
return None
return value
class _UnsafeQBittorrentPath:
pass
@@ -665,6 +672,10 @@ class QBittorrentClient(DownloadClient):
torrent_speed = getattr(torrent, "dlspeed", None)
torrent_speed = torrent_speed if isinstance(torrent_speed, int) else None
connected_seeds = _non_negative_int(getattr(torrent, "num_seeds", None))
swarm_seeds = _non_negative_int(getattr(torrent, "num_complete", None))
connected_peers = _non_negative_int(getattr(torrent, "num_leechs", None))
return DownloadStatus(
progress=float(torrent_progress) * 100,
state="complete" if complete else state,
@@ -673,6 +684,9 @@ class QBittorrentClient(DownloadClient):
file_path=file_path,
download_speed=torrent_speed,
eta=eta,
seeders=connected_seeds,
swarm_seeders=swarm_seeds,
peers=connected_peers,
)
except _QBITTORRENT_CLIENT_ERRORS as e:
return DownloadStatus.error(self._log_error("get_status", e))
+25
View File
@@ -949,6 +949,31 @@ def prowlarr_clients_settings() -> list[SettingsField]:
default="keep",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
),
NumberField(
key="TORRENT_NO_SEEDERS_WAIT",
label="Give Up With No Seeders (minutes)",
description=(
"Give up on a torrent when its client sees no seed at all for this long, even "
"while it is still downloading, or fewer seeds than the minimum below while "
"nothing is downloading. The torrent is removed and the download marked failed, "
"so another release can be tried. The card shows the seed count and a countdown "
"while it waits. 0 keeps the default behaviour (only the stall timer ends it). "
"Only qBittorrent reports seed counts."
),
default=0,
min_value=0,
max_value=240,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
),
NumberField(
key="TORRENT_MIN_SEEDERS",
label="Minimum Seeders",
description="Seeds the client must see for a motionless torrent to keep waiting. 0 also turns the no-seed check off.",
default=1,
min_value=0,
max_value=1000,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
),
TextField(
key="PROWLARR_TORRENT_POST_IMPORT_CATEGORY",
label="Post-Import Category",
+231
View File
@@ -0,0 +1,231 @@
"""A torrent that is not moving and has too few seeds is given up on, and the card says why."""
from __future__ import annotations
import sys
from threading import Event
from unittest.mock import MagicMock, patch
from shelfmark.core.models import DownloadTask
from shelfmark.download.clients import DownloadState, DownloadStatus
from shelfmark.download.clients.qbittorrent import _non_negative_int
from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler
bh = sys.modules["shelfmark.download.clients.base_handler"] # loaded by the import above
D = DownloadState.DOWNLOADING
WAIT_MINUTES = 5
def _status(
progress: float = 0.0,
*,
seeders: int | None = 0,
swarm_seeders: int | None = None,
state: DownloadState = D,
) -> DownloadStatus:
return DownloadStatus(
progress=progress,
state=state,
message=None,
complete=False,
file_path=None,
seeders=seeders,
swarm_seeders=swarm_seeders,
)
def _poll(
statuses: list[DownloadStatus],
clock: list[float],
*,
wait_minutes: float = WAIT_MINUTES,
min_seeders: int = 1,
added_by_shelfmark: bool = True,
) -> tuple[list[tuple[str, str | None]], MagicMock, str | None]:
cancel = Event()
seen = iter(statuses)
client = MagicMock()
client.name = "qbittorrent"
def get_status(_id: str) -> DownloadStatus:
try:
return next(seen)
except StopIteration:
cancel.set()
return statuses[-1]
client.get_status.side_effect = get_status
client.remove.return_value = True
events: list[tuple[str, str | None]] = []
ticks = iter(clock)
with (
patch.object(ProwlarrHandler, "_poll_interval", return_value=0.0),
patch.object(ProwlarrHandler, "_no_seeders_wait_seconds", return_value=wait_minutes * 60.0),
patch.object(ProwlarrHandler, "_min_seeders", return_value=min_seeders),
patch.object(bh.time, "monotonic", side_effect=lambda: next(ticks)),
):
result = ProwlarrHandler()._poll_and_complete(
client,
"hash",
"torrent",
DownloadTask(task_id="t", source="prowlarr", title="Book"),
cancel,
lambda _p: None,
lambda status, message: events.append((status, message)),
added_by_shelfmark=added_by_shelfmark,
)
return events, client, result
def _messages(events: list[tuple[str, str | None]]) -> list[str]:
return [m or "" for s, m in events if s == "downloading"]
def test_gives_up_when_nothing_moves_and_no_seeds_are_seen() -> None:
events, client, result = _poll([_status()] * 4, [0.0, 100.0, 200.0, 301.0])
assert result is None
assert events[-1] == ("error", "No seeders: 0 found, 1 needed, for 5 min (0% downloaded)")
client.remove.assert_called_once_with("hash", delete_files=True)
def test_the_card_counts_down_while_it_waits() -> None:
events, _client, _result = _poll([_status()] * 4, [0.0, 100.0, 200.0, 301.0])
assert _messages(events)[:3] == [
"0% · 0 seeds, giving up in 5m",
"0% · 0 seeds, giving up in 4m",
"0% · 0 seeds, giving up in 2m",
]
def test_a_torrent_with_no_seed_at_all_is_given_up_on_even_while_it_moves() -> None:
statuses = [_status(1.0), _status(2.0), _status(3.0), _status(4.0)]
events, client, _result = _poll(statuses, [0.0, 400.0, 800.0, 1200.0])
assert events[-1] == ("error", "No seeders: 0 found, 1 needed, for 5 min (2% downloaded)")
client.remove.assert_called_once_with("hash", delete_files=True)
def test_a_torrent_that_is_moving_is_kept_while_it_still_has_a_seed() -> None:
statuses = [_status(1.0, seeders=1), _status(2.0, seeders=1), _status(3.0, seeders=1)]
events, client, _result = _poll(statuses, [0.0, 400.0, 800.0], min_seeders=3)
assert not any(s == "error" for s, _m in events)
client.remove.assert_not_called()
def test_the_clock_restarts_when_progress_appears() -> None:
statuses = [_status(0.0, seeders=1), _status(0.0, seeders=1), _status(1.0, seeders=1)]
events, _client, _result = _poll(statuses, [0.0, 200.0, 500.0], min_seeders=3)
assert not any(s == "error" for s, _m in events)
def test_enough_seeds_keeps_a_motionless_torrent_waiting() -> None:
events, _client, _result = _poll([_status(seeders=2)] * 3, [0.0, 600.0, 1200.0])
assert not any(s == "error" for s, _m in events)
def test_the_tracker_count_also_counts_as_a_seed() -> None:
events, _client, _result = _poll(
[_status(seeders=0, swarm_seeders=4)] * 3, [0.0, 600.0, 1200.0]
)
assert not any(s == "error" for s, _m in events)
def test_a_higher_minimum_is_honoured() -> None:
events, _client, _result = _poll([_status(seeders=1)] * 3, [0.0, 200.0, 400.0], min_seeders=3)
assert events[-1][0] == "error"
assert events[-1][1] == "No seeders: 1 found, 3 needed, for 5 min (0% downloaded)"
def test_off_by_default_never_gives_up() -> None:
events, _client, _result = _poll([_status()] * 3, [0.0, 600.0, 1200.0], wait_minutes=0)
assert not any(s == "error" for s, _m in events)
def test_a_client_that_reports_no_counts_is_left_to_the_stall_timer() -> None:
events, _client, _result = _poll([_status(seeders=None)] * 3, [0.0, 600.0, 1200.0])
assert not any(s == "error" for s, _m in events)
def test_a_torrent_that_was_already_in_the_client_is_not_removed() -> None:
events, client, _result = _poll(
[_status()] * 4, [0.0, 100.0, 200.0, 301.0], added_by_shelfmark=False
)
assert events[-1][0] == "error"
client.remove.assert_not_called()
def test_a_queued_torrent_is_not_judged() -> None:
events, _client, _result = _poll(
[_status(state=DownloadState.QUEUED)] * 3, [0.0, 600.0, 1200.0, 1800.0, 2400.0]
)
assert not any(s == "error" for s, _m in events)
def test_seed_counts_show_beside_progress_when_nothing_is_wrong() -> None:
events, _client, _result = _poll(
[_status(10.0, seeders=3), _status(11.0, seeders=1)], [0.0, 1.0]
)
assert _messages(events)[:2] == ["10% · 3 seeds", "11% · 1 seed"]
def test_a_clients_unknown_count_is_not_a_zero() -> None:
assert _non_negative_int(-1) is None
assert _non_negative_int(None) is None
assert _non_negative_int(True) is None
assert _non_negative_int(0) == 0
assert _non_negative_int(7) == 7
def test_a_torrent_that_started_with_enough_seeds_keeps_going_as_they_leave() -> None:
# Met the minimum of 3 when it started, then dropped to 1 while still moving.
statuses = [
_status(10.0, seeders=3),
_status(30.0, seeders=2),
_status(50.0, seeders=1),
_status(60.0, seeders=1),
_status(70.0, seeders=1),
]
events, client, _result = _poll(
statuses, [0.0, 600.0, 1200.0, 1800.0, 2400.0], min_seeders=3, added_by_shelfmark=False
)
assert not any(s == "error" for s, _m in events)
client.remove.assert_not_called()
def test_the_last_seed_leaving_starts_the_clock_even_if_it_is_still_moving() -> None:
statuses = [
_status(10.0, seeders=3),
_status(50.0, seeders=0),
_status(60.0, seeders=0),
_status(70.0, seeders=0),
]
events, _client, _result = _poll(statuses, [0.0, 150.0, 400.0], min_seeders=3)
assert events[-1] == ("error", "No seeders: 0 found, 3 needed, for 5 min (70% downloaded)")
def test_no_seed_is_not_judged_when_the_minimum_is_zero() -> None:
events, _client, _result = _poll([_status()] * 3, [0.0, 600.0, 1200.0], min_seeders=0)
assert not any(s == "error" for s, _m in events)
def test_it_gives_up_only_once_a_torrent_that_lost_its_seeds_stops_moving() -> None:
statuses = [_status(50.0, seeders=1), _status(50.0, seeders=0), _status(50.0, seeders=0)]
events, _client, _result = _poll(statuses, [0.0, 100.0, 400.0], min_seeders=3)
assert events[-1][0] == "error"