Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -1734,6 +1734,7 @@ How long to keep cached search results before they expire.
| `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` |
| `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_ |
| `NZBGET_URL` | URL of your NZBGet instance | string | _none_ |
| `NZBGET_USERNAME` | NZBGet control username | string | `nzbget` |
Expand Down Expand Up @@ -2057,6 +2058,16 @@ Category or label to assign after a successful import
- **Type:** string
- **Default:** _empty string_

#### `TORRENT_STALL_TIMEOUT_MINUTES`

**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.

- **Type:** number
- **Default:** `15`
- **Constraints:** min: 5, max: 60

#### `PROWLARR_USENET_CLIENT`

**Usenet Client**
Expand Down
5 changes: 5 additions & 0 deletions shelfmark/download/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@

from collections.abc import Callable

# Seconds without a changed status or progress value before the orchestrator cancels a
# download. Lives here so the settings module can derive its minimum without importing the
# orchestrator.
STALL_TIMEOUT_SECONDS = 300

# Not a QueueStatus value, so `update_download_status` would reject it anyway; the
# orchestrator's status_callback intercepts it before that point.
ACTIVITY_GRACE_STATUS = "__activity_grace__"
Expand Down
59 changes: 46 additions & 13 deletions shelfmark/download/clients/base_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
get_client,
list_configured_clients,
)
from shelfmark.download.clients.settings import (
STALL_TIMEOUT_DEFAULT_MINUTES,
STALL_TIMEOUT_MAX_MINUTES,
STALL_TIMEOUT_MIN_MINUTES,
STALL_TIMEOUT_SETTING,
)
from shelfmark.download.fs import run_blocking_io
from shelfmark.download.permissions_debug import log_path_permission_context
from shelfmark.release_sources import DownloadHandler, HandoffResult
Expand Down Expand Up @@ -53,18 +59,18 @@ def _is_sabnzbd_like_client(candidate: DownloadClient) -> TypeGuard[_SabnzbdLike
POLL_INTERVAL = 2
# A torrent the client has queued is waiting for a free slot, not stalled. Its status never
# changes while it waits, so the orchestrator's stall timer would cancel it after
# STALL_TIMEOUT. Ask for a grace instead (the orchestrator caps one grace at 16 minutes),
# STALL_TIMEOUT. Ask for a grace instead (the orchestrator caps one grace at an hour),
# renew it while the torrent is still queued, and stop at a hard ceiling so a queue that
# never moves still ends.
QUEUE_GRACE_SECONDS = 900.0
QUEUE_GRACE_RENEW_SECONDS = 600.0
QUEUE_MAX_WAIT_SECONDS = 7200.0
# Torrents are jerky: a swarm with one seed can sit still for several minutes and then carry
# on. The orchestrator's default window (STALL_TIMEOUT) is five minutes of no change. A torrent
# that has already moved gets this longer window, restarted every time its progress goes up,
# so a dead one still ends 15 minutes after its last movement. One that never moved (a magnet
# that cannot fetch metadata) keeps the short window and is cleaned up quickly.
MOVING_STALL_SECONDS = 900.0
# on, and a magnet can take a while to fetch metadata or find a first peer. The orchestrator
# ends a download after STALL_TIMEOUT (five minutes) without a change. This setting is the
# 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.
WINDOWS_DRIVE_PREFIX_LENGTH = 2
SECONDS_PER_MINUTE = 60
SECONDS_PER_HOUR = 3600
Expand Down Expand Up @@ -217,6 +223,19 @@ def _list_configured_clients(self) -> list[str]:
"""List protocols with configured clients."""
return list_configured_clients()

@staticmethod
def _stall_window_seconds() -> float:
"""How long a torrent may go without progress, from the user's setting (seconds)."""
raw = config.get(STALL_TIMEOUT_SETTING, STALL_TIMEOUT_DEFAULT_MINUTES)
try:
minutes = float(raw) # pyright: ignore[reportArgumentType]
except TypeError, ValueError:
minutes = float(STALL_TIMEOUT_DEFAULT_MINUTES)
if not math.isfinite(minutes):
minutes = float(STALL_TIMEOUT_DEFAULT_MINUTES)
minutes = min(max(minutes, STALL_TIMEOUT_MIN_MINUTES), STALL_TIMEOUT_MAX_MINUTES)
return minutes * SECONDS_PER_MINUTE

def _poll_interval(self) -> float:
"""Return the polling interval for status checks."""
return POLL_INTERVAL
Expand Down Expand Up @@ -986,9 +1005,18 @@ def _poll_and_complete(
"""
poll_interval = self._poll_interval()
queued_since: float | None = None
grace_requested_at = 0.0
# None, not 0.0: time.monotonic() can be below the renew interval on a host that
# booted recently, which would skip the first request.
grace_requested_at: float | None = None
last_progress = 0.0
start_window_given = False
# The setting is for torrents. Usenet keeps the orchestrator's own timeout, which is
# the floor of the setting, so no window is requested for it.
stall_window = (
self._stall_window_seconds()
if protocol == "torrent"
else float(STALL_TIMEOUT_MIN_MINUTES * SECONDS_PER_MINUTE)
)
# 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
Expand Down Expand Up @@ -1078,24 +1106,29 @@ def _poll_and_complete(
now = time.monotonic()
if queued_since is None:
queued_since = now
if (
now - queued_since < QUEUE_MAX_WAIT_SECONDS
and now - grace_requested_at >= QUEUE_GRACE_RENEW_SECONDS
if now - queued_since < QUEUE_MAX_WAIT_SECONDS and (
grace_requested_at is None
or now - grace_requested_at >= QUEUE_GRACE_RENEW_SECONDS
):
request_activity_grace(status_callback, QUEUE_GRACE_SECONDS)
grace_requested_at = now
elif queued_since is not None:
release_activity_grace(status_callback)
queued_since = None
grace_requested_at = 0.0
grace_requested_at = None
# The release drops the stall deadline back to the orchestrator's own
# timeout, so the torrent needs a fresh window even if it had one before.
start_window_given = False

if status.progress > last_progress:
request_activity_grace(status_callback, MOVING_STALL_SECONDS)
if stall_window > STALL_TIMEOUT_MIN_MINUTES * SECONDS_PER_MINUTE:
request_activity_grace(status_callback, stall_window)
start_window_given = True
elif not start_window_given and status.state != DownloadState.QUEUED:
# Metadata and the first peer can take longer than the orchestrator's
# stall timeout, so a torrent gets one full window to get going.
request_activity_grace(status_callback, MOVING_STALL_SECONDS)
if stall_window > STALL_TIMEOUT_MIN_MINUTES * SECONDS_PER_MINUTE:
request_activity_grace(status_callback, stall_window)
start_window_given = True
last_progress = status.progress

Expand Down
23 changes: 23 additions & 0 deletions shelfmark/download/clients/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from shelfmark.core.settings_registry import (
ActionButton,
HeadingField,
NumberField,
PasswordField,
SelectField,
SettingsField,
Expand All @@ -17,6 +18,7 @@
register_settings,
)
from shelfmark.core.utils import get_hardened_xmlrpc_client, normalize_http_url
from shelfmark.download.activity import STALL_TIMEOUT_SECONDS
from shelfmark.download.network import get_ssl_verify

try:
Expand Down Expand Up @@ -604,6 +606,13 @@ def _test_torbox_connection(current_values: dict[str, Any] | None = None) -> dic
# ==================== Download Clients Tab ====================


# Torrent stall window (see ExternalClientHandler._stall_window_seconds).
STALL_TIMEOUT_SETTING = "TORRENT_STALL_TIMEOUT_MINUTES"
STALL_TIMEOUT_DEFAULT_MINUTES = 15
STALL_TIMEOUT_MIN_MINUTES = STALL_TIMEOUT_SECONDS // 60 # a shorter window cannot apply
STALL_TIMEOUT_MAX_MINUTES = 60


@register_settings(
name="prowlarr_clients",
display_name="Download Clients",
Expand Down Expand Up @@ -948,6 +957,20 @@ def prowlarr_clients_settings() -> list[SettingsField]:
default="",
show_when={"field": "PROWLARR_TORRENT_ACTION", "value": "change_category"},
),
NumberField(
key=STALL_TIMEOUT_SETTING,
label="Stall Timeout (minutes)",
description=(
"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."
),
default=STALL_TIMEOUT_DEFAULT_MINUTES,
min_value=STALL_TIMEOUT_MIN_MINUTES,
max_value=STALL_TIMEOUT_MAX_MINUTES,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
),
# --- Usenet Client Selection ---
HeadingField(
key="usenet_heading",
Expand Down
17 changes: 13 additions & 4 deletions shelfmark/download/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
)
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.core.utils import transform_cover_url
from shelfmark.download.activity import parse_activity_grace
from shelfmark.download.activity import STALL_TIMEOUT_SECONDS, parse_activity_grace
from shelfmark.download.fs import run_blocking_io
from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path
from shelfmark.download.postprocess.router import post_process_download
Expand Down Expand Up @@ -69,14 +69,18 @@
_last_progress_value: dict[str, float] = {}
# De-duplicate status updates (keep-alive updates shouldn't spam clients)
_last_status_event: dict[str, tuple[str, str | None]] = {}
STALL_TIMEOUT = 300 # 5 minutes without progress/status update = stalled
STALL_TIMEOUT = STALL_TIMEOUT_SECONDS # 5 minutes without progress/status update = stalled
# Absolute deadlines (time.time()) until which stall detection is suppressed for a task.
# Long single-shot operations (protection bypass, etc.) declare their own upper bound via
# `shelfmark.download.activity` instead of faking progress. See set_activity_grace().
_activity_grace: dict[str, float] = {}
# The size of the last grace a task asked for, so a stall message can report the window the
# task actually had instead of the default.
_activity_grace_seconds: dict[str, float] = {}
# A caller cannot buy immortality: the largest grace any operation may request. Must stay
# above the largest budget any caller can declare (see http._bypass_grace_seconds).
_MAX_ACTIVITY_GRACE_SECONDS = 960.0
# The torrent stall setting (base_handler.STALL_TIMEOUT_MAX_MINUTES) can ask for up to an hour.
_MAX_ACTIVITY_GRACE_SECONDS = 3600.0
COORDINATOR_LOOP_ERROR_RETRY_DELAY = 1.0
# Ceiling for the exponential backoff applied to repeated coordinator loop failures.
_COORDINATOR_LOOP_ERROR_MAX_DELAY = 30.0
Expand Down Expand Up @@ -1014,6 +1018,7 @@ def _cleanup_progress_tracking(task_id: str) -> None:
_last_progress_value.pop(task_id, None)
_last_status_event.pop(task_id, None)
_activity_grace.pop(task_id, None)
_activity_grace_seconds.pop(task_id, None)


def _finalize_download_failure(task_id: str) -> None:
Expand Down Expand Up @@ -1077,6 +1082,7 @@ def set_activity_grace(book_id: str, seconds: float) -> None:
grace = min(max(grace, 0.0), _MAX_ACTIVITY_GRACE_SECONDS)
with _progress_lock:
_activity_grace[book_id] = time.time() + grace
_activity_grace_seconds[book_id] = grace


def clear_activity_grace(book_id: str) -> None:
Expand All @@ -1087,6 +1093,7 @@ def clear_activity_grace(book_id: str) -> None:
"""
with _progress_lock:
_activity_grace.pop(book_id, None)
_activity_grace_seconds.pop(book_id, None)
_last_activity[book_id] = time.time()


Expand Down Expand Up @@ -1116,9 +1123,11 @@ def _cancel_stalled_task(task_id: str) -> None:
logger.warning("Download stalled for %s, cancelling", task_id)
# The message goes first: the terminal hook copies it into the history row, and a cancel
# that records the last poll's message ("Queued") hides that the timer ended the download.
with _progress_lock:
window = max(STALL_TIMEOUT, _activity_grace_seconds.get(task_id, 0.0))
book_queue.update_status_message(
task_id,
f"Download stalled (no activity for {STALL_TIMEOUT}s)",
f"Download stalled (no activity for {int(window)}s)",
)
book_queue.cancel_download(task_id)

Expand Down
30 changes: 30 additions & 0 deletions tests/download/test_orchestrator_stall.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def _reset(orchestrator) -> None:
orchestrator._last_progress_value.clear()
orchestrator._last_status_event.clear()
orchestrator._activity_grace.clear()
orchestrator._activity_grace_seconds.clear()


def test_find_stalled_tasks_flags_task_past_stall_timeout():
Expand Down Expand Up @@ -305,3 +306,32 @@ def test_the_stall_reason_is_set_before_the_cancel_finalises_history(monkeypatch

names = [call[0] for call in mock_queue.method_calls]
assert names.index("update_status_message") < names.index("cancel_download")


def test_the_stall_message_reports_the_window_the_task_was_given(monkeypatch):
import shelfmark.download.orchestrator as orchestrator

_reset(orchestrator)
monkeypatch.setattr(orchestrator, "book_queue", MagicMock())
orchestrator.set_activity_grace("book", 1800)

orchestrator._cancel_stalled_task("book")

orchestrator.book_queue.update_status_message.assert_called_once_with(
"book", "Download stalled (no activity for 1800s)"
)


def test_a_released_grace_goes_back_to_the_default_in_the_stall_message(monkeypatch):
import shelfmark.download.orchestrator as orchestrator

_reset(orchestrator)
monkeypatch.setattr(orchestrator, "book_queue", MagicMock())
orchestrator.set_activity_grace("book", 1800)
orchestrator.clear_activity_grace("book")

orchestrator._cancel_stalled_task("book")

orchestrator.book_queue.update_status_message.assert_called_once_with(
"book", f"Download stalled (no activity for {orchestrator.STALL_TIMEOUT}s)"
)
Loading
Loading