mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-12 23:58:29 +00:00
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:
1 parent
9ba5816b83
commit
e83bab9337
6 files changed
+409
-1
No files matched your search
@@ -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**
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user