From 93210960326f99d731b3f3f91722cf2ebebf1bfd Mon Sep 17 00:00:00 2001 From: CaliBrain Date: Sat, 3 Oct 2026 08:08:32 -0400 Subject: [PATCH] fix(clients): correct Debrid-Link file listing and rate-limit handling (#1425) Follow-up to #1380. - Fetch the file list with ?ids=, the documented parameter. There is no ?id=, so a many-file torrent was not expanded and the call could fail with badArguments at the last step of every download. - Back off status checks for 60s after floodDetected, doubling to a 240s cap, instead of an hour. The orchestrator cancels a download after five minutes without a change, so the hour-long pause cancelled every active download. Only a flood on the status check starts it, and a successful check resets it. - Drop the client-side one-hour stall timer. The orchestrator stall timer always fires first. - Keep progress at 50% when file retrieval starts instead of dropping to 0% until the first file arrives. - Report queued, paused and verifying torrents as QUEUED, PAUSED and CHECKING, so a queued torrent gets the handler queue grace. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Claude Opus 5.5 --- shelfmark/download/clients/debridlink.py | 97 +++++++--------- tests/prowlarr/test_debridlink_client.py | 139 +++++++++++++++-------- 2 files changed, 135 insertions(+), 101 deletions(-) diff --git a/shelfmark/download/clients/debridlink.py b/shelfmark/download/clients/debridlink.py index 19854878..7c72fbd0 100644 --- a/shelfmark/download/clients/debridlink.py +++ b/shelfmark/download/clients/debridlink.py @@ -75,21 +75,22 @@ _STATUS_FINISHED = 100 _DONE_STATUSES = frozenset({_STATUS_SEEDING, _STATUS_FINISHED}) # The download handler polls every couple of seconds. Debrid-Link rate-limits per -# endpoint and answers a breach with floodDetected, which blocks the API for an hour, -# so the seedbox is asked at most this often per torrent and cached status is served -# in between. +# endpoint and answers a breach with floodDetected, so the seedbox is asked at most +# this often per torrent and cached status is served in between. _MIN_REFRESH_SECONDS = 15.0 -_FLOOD_BACKOFF_SECONDS = 3600.0 + +# floodDetected only says to retry later; the docs give no duration. Status checks +# back off for a minute, doubling while the flood lasts. Each new back-off changes the +# status message, and the cap keeps the next check inside the orchestrator's +# five-minute stall window, so waiting out a flood doesn't get a download cancelled. +_FLOOD_BACKOFF_SECONDS = 60.0 +_FLOOD_BACKOFF_MAX_SECONDS = 240.0 # Errors Debrid-Link documents as temporary. They, network failures and 5xx # responses are retried until the torrent has been failing for this long. _TRANSIENT_ERROR_CODES = frozenset({"internalError", "server_error", "freeServerOverload"}) _TRANSIENT_GRACE_SECONDS = 600.0 -# A torrent whose progress has not moved for this long is treated as dead (no seeds, -# or left paused), because the API documents no error status for a torrent. -_STALL_TIMEOUT_SECONDS = 3600.0 - # How long remove() waits for the retrieval thread to notice a cancel. _WORKER_JOIN_TIMEOUT = 10.0 @@ -118,8 +119,7 @@ _BOOK_EXTENSIONS = ( ".rtf", ".txt", ".wma", - # A torrent with many files can come back as a single ZIP of all of them; the - # post-processing step extracts it. + # A zipped book or audiobook; the post-processing step extracts it. ".zip", ) @@ -154,11 +154,8 @@ class _DownloadState: last_fetch_at: float = 0.0 # Transient-failure tracking: when the current run of failures started. failing_since: float | None = None - # Stall tracking: the highest progress seen and when it last moved. - best_percent: float = -1.0 # Set once a torrent held for file selection (``wait``) has been told to start. start_requested: bool = False - progress_moved_at: float = field(default_factory=time.monotonic) @register_client("torrent") @@ -177,8 +174,10 @@ class DebridLinkClient(DownloadClient): _downloads: ClassVar[dict[str, _DownloadState]] = {} _downloads_lock = threading.Lock() - # floodDetected blocks the whole account's API use, so the back-off is shared. + # A flood on the status endpoint applies to every torrent on the account, so the + # status back-off is shared. _flood_until: ClassVar[float] = 0.0 + _flood_backoff: ClassVar[float] = 0.0 def __init__(self) -> None: self._api_key = config_text(config.get("DEBRIDLINK_API_KEY", "")) @@ -333,13 +332,14 @@ class DebridLinkClient(DownloadClient): cached, f"Debrid-Link rate limit reached, retrying at {resume}" ) if fresh and cached is not None: - return self._handle_torrent_info(cached, state, observed=False) + return self._handle_torrent_info(cached, state) try: torrent = self._fetch_torrent(download_id) except _DEBRIDLINK_CLIENT_ERRORS as e: return self._handle_fetch_failure(e, state) + type(self)._flood_backoff = 0.0 with state.lock: state.failing_since = None state.last_fetch_at = time.monotonic() @@ -456,8 +456,6 @@ class DebridLinkClient(DownloadClient): # The documented error body is just {success: false, error: }. code = payload.get("error") code = code if isinstance(code, str) and code else None - if code == "floodDetected": - type(self)._flood_until = time.time() + _FLOOD_BACKOFF_SECONDS detail = code or ("an unsuccessful response" if ok_status else f"HTTP {status_code}") msg = f"Debrid-Link {operation} failed: {detail}" raise DebridLinkAPIError(msg, code or (None if ok_status else f"http{status_code}")) @@ -483,19 +481,12 @@ class DebridLinkClient(DownloadClient): def _fetch_file_list(self, download_id: str) -> list[dict[str, Any]]: """Return the torrent's individual files. - A torrent with many files is listed as one ZIP (``isZip``) under ``ids``; - Debrid-Link documents ``?id=TORRENT_ID`` as the way to see the files - themselves. Fetched once, after the torrent is complete, so the list is - final rather than the partial one an early status check can return. + The plain list shows a torrent with many files as one ZIP (``isZip``); + asking for it by ``ids``, as ``_fetch_torrent`` does, lists every file. + Fetched once, after the torrent is complete, so the list is final rather + than the partial one an early status check can return. """ - value = self._request_value( - "GET", - "/seedbox/list", - operation="file listing", - params={"id": download_id}, - timeout=_STATUS_TIMEOUT, - ) - torrent = self._pick_torrent(value, download_id) + torrent = self._fetch_torrent(download_id) if torrent is None: msg = f"Torrent {download_id} is no longer on Debrid-Link" _raise_runtime_error(msg) @@ -513,7 +504,7 @@ class DebridLinkClient(DownloadClient): """Fail on a permanent error; ride out a temporary one for a grace period.""" code = getattr(error, "code", None) if code == "floodDetected": - resume = time.strftime("%H:%M", time.localtime(type(self)._flood_until)) + resume = self._start_flood_backoff() logger.warning("Debrid-Link rate limit reached; pausing status checks until %s", resume) return self._waiting_status( state.last_info, f"Debrid-Link rate limit reached, retrying at {resume}" @@ -539,6 +530,15 @@ class DebridLinkClient(DownloadClient): logger.warning("Debrid-Link status check failed, will retry: %s", error) return self._waiting_status(state.last_info, "Debrid-Link unreachable, retrying") + @classmethod + def _start_flood_backoff(cls) -> str: + """Pause status checks after floodDetected and return the resume time as HH:MM.""" + cls._flood_backoff = min( + max(cls._flood_backoff * 2, _FLOOD_BACKOFF_SECONDS), _FLOOD_BACKOFF_MAX_SECONDS + ) + cls._flood_until = time.time() + cls._flood_backoff + return time.strftime("%H:%M", time.localtime(cls._flood_until)) + @staticmethod def _waiting_status(info: dict[str, Any] | None, message: str) -> DownloadStatus: percent = _as_float(info.get("downloadPercent")) if info else 0.0 @@ -575,17 +575,11 @@ class DebridLinkClient(DownloadClient): self._downloads[download_id] = state return state - def _handle_torrent_info( - self, - info: dict[str, Any], - state: _DownloadState, - *, - observed: bool = True, - ) -> DownloadStatus: + def _handle_torrent_info(self, info: dict[str, Any], state: _DownloadState) -> DownloadStatus: """Map a Debrid-Link seedbox torrent to a DownloadStatus. - ``observed`` is False when ``info`` is a cached record being re-served - between throttled fetches, so it doesn't count toward stall detection. + The API documents no error status for a torrent, so a dead one is left to the + orchestrator's stall timer, as with the other torrent clients. """ percent = _as_float(info.get("downloadPercent")) name = str(info.get("name") or state.name) @@ -607,20 +601,7 @@ class DebridLinkClient(DownloadClient): file_path=None, ) - now = time.monotonic() - with state.lock: - if percent > state.best_percent: - state.best_percent = percent - state.progress_moved_at = now - stalled_for = now - state.progress_moved_at - if observed and stalled_for >= _STALL_TIMEOUT_SECONDS: - minutes = int(_STALL_TIMEOUT_SECONDS // 60) - return self._set_error( - state, - f"Debrid-Link made no progress on {name} for {minutes} minutes " - f"({int(_as_float(info.get('peersConnected')))} peers connected)", - ) - + download_state = DownloadState.DOWNLOADING if info.get("wait"): # Held for file selection. Shelfmark wants every file, so start it. self._start_waiting_torrent(state) @@ -628,10 +609,15 @@ class DebridLinkClient(DownloadClient): elif info.get("srvMaint"): message = "Debrid-Link server maintenance, waiting" elif status == _STATUS_PAUSED: + download_state = DownloadState.PAUSED message = f"Paused on Debrid-Link ({name})" elif status == _STATUS_QUEUED: + # Reported as queued so the handler gives it the queue grace, not the + # stall window for a torrent that should be moving. + download_state = DownloadState.QUEUED message = f"Queued on Debrid-Link ({name})" elif status == _STATUS_VERIFYING: + download_state = DownloadState.CHECKING message = f"Debrid-Link verifying torrent ({name})" else: message = f"Debrid-Link downloading torrent ({name})" @@ -639,7 +625,7 @@ class DebridLinkClient(DownloadClient): # Still being fetched by the seedbox: the first half of the progress bar. return DownloadStatus( progress=min(percent, _COMPLETE_PERCENT) * 0.5, - state=DownloadState.DOWNLOADING, + state=download_state, message=message, complete=False, file_path=None, @@ -679,6 +665,9 @@ class DebridLinkClient(DownloadClient): if already_running or thread_alive or state.cancel_event.is_set(): return state.phase = "downloading_http" + # The seedbox half is done; without this the bar drops to 0% until the + # first file arrives. + state.progress = 50.0 t = threading.Thread( target=self._process_and_download, args=(state,), diff --git a/tests/prowlarr/test_debridlink_client.py b/tests/prowlarr/test_debridlink_client.py index 22e5f363..9cd3d299 100644 --- a/tests/prowlarr/test_debridlink_client.py +++ b/tests/prowlarr/test_debridlink_client.py @@ -9,6 +9,7 @@ step to mock. import hashlib import io +import threading from unittest.mock import MagicMock import pytest @@ -66,9 +67,11 @@ def _mock_request(monkeypatch, payload, *, status_code=200): def _reset_shared_state(): """The flood back-off and the download table are shared across instances.""" DebridLinkClient._flood_until = 0.0 + DebridLinkClient._flood_backoff = 0.0 DebridLinkClient._downloads.clear() yield DebridLinkClient._flood_until = 0.0 + DebridLinkClient._flood_backoff = 0.0 DebridLinkClient._downloads.clear() @@ -147,13 +150,13 @@ class TestEnvelope: assert "debrid-link.com" not in str(err.value) assert "ConnectionError" in str(err.value) - def test_flood_detected_starts_the_shared_back_off(self, monkeypatch): + def test_flood_detected_is_raised_with_its_code(self, monkeypatch): _mock_request(monkeypatch, {"success": False, "error": "floodDetected"}, status_code=429) client = _client(monkeypatch) - with pytest.raises(DebridLinkAPIError): + with pytest.raises(DebridLinkAPIError) as err: client._request_value("GET", "/seedbox/list", operation="status check") - assert DebridLinkClient._flood_until > 0 + assert err.value.code == "floodDetected" class TestAdd: @@ -265,23 +268,24 @@ class TestStatus: started.assert_not_called() @pytest.mark.parametrize( - ("info", "expected"), + ("info", "expected_state", "expected_message"), [ - ({"status": 0}, "Paused on Debrid-Link"), - ({"status": 1}, "Queued on Debrid-Link"), - ({"status": 2}, "verifying"), - ({"status": 4}, "downloading torrent"), - ({"status": 6}, "downloading torrent"), # the docs' own example value - ({"srvMaint": True}, "server maintenance"), + ({"status": 0}, DownloadState.PAUSED, "Paused on Debrid-Link"), + ({"status": 1}, DownloadState.QUEUED, "Queued on Debrid-Link"), + ({"status": 2}, DownloadState.CHECKING, "verifying"), + ({"status": 4}, DownloadState.DOWNLOADING, "downloading torrent"), + # The docs' own example value. + ({"status": 6}, DownloadState.DOWNLOADING, "downloading torrent"), + ({"status": 1, "srvMaint": True}, DownloadState.DOWNLOADING, "server maintenance"), ], ) - def test_documented_states_are_reported(self, tmp_path, info, expected): + def test_documented_states_are_reported(self, tmp_path, info, expected_state, expected_message): status = self._bare_client()._handle_torrent_info( {"downloadPercent": 10, **info}, _state(tmp_path) ) - assert status.state == DownloadState.DOWNLOADING - assert expected in (status.message or "") + assert status.state == expected_state + assert expected_message in (status.message or "") @pytest.mark.parametrize("done_status", [8, 100]) def test_seeding_or_finished_counts_as_done(self, tmp_path, monkeypatch, done_status): @@ -317,40 +321,23 @@ class TestStatus: assert args[1].endswith("/seedbox/DL1/config") assert kwargs["json"] == {"files-unwanted": []} - def test_a_torrent_that_stops_moving_fails_after_the_stall_timeout(self, tmp_path, monkeypatch): + def test_retrieval_starts_at_half_progress_rather_than_zero(self, tmp_path, monkeypatch): client = self._bare_client() state = _state(tmp_path) - clock = [1000.0] - monkeypatch.setattr(debridlink.time, "monotonic", lambda: clock[0]) - state.progress_moved_at = clock[0] + DebridLinkClient._downloads["DL1"] = state + release = threading.Event() + monkeypatch.setattr(client, "_process_and_download", lambda _state: release.wait(5)) - client._handle_torrent_info({"downloadPercent": 12, "peersConnected": 0}, state) - clock[0] += debridlink._STALL_TIMEOUT_SECONDS - 1 - assert client._handle_torrent_info({"downloadPercent": 12}, state).state == ( - DownloadState.DOWNLOADING - ) + client._maybe_start_download_thread(state) + try: + status = client.get_status("DL1") + finally: + release.set() + assert state.download_thread is not None + state.download_thread.join(5) - clock[0] += 2 - status = client._handle_torrent_info({"downloadPercent": 12, "peersConnected": 0}, state) - - assert status.state == DownloadState.ERROR - assert "no progress" in (status.message or "") - - def test_progress_resets_the_stall_clock(self, tmp_path, monkeypatch): - client = self._bare_client() - state = _state(tmp_path) - clock = [1000.0] - monkeypatch.setattr(debridlink.time, "monotonic", lambda: clock[0]) - state.progress_moved_at = clock[0] - - client._handle_torrent_info({"downloadPercent": 12}, state) - clock[0] += debridlink._STALL_TIMEOUT_SECONDS - 1 - client._handle_torrent_info({"downloadPercent": 13}, state) - clock[0] += debridlink._STALL_TIMEOUT_SECONDS - 1 - - assert client._handle_torrent_info({"downloadPercent": 13}, state).state == ( - DownloadState.DOWNLOADING - ) + assert status.message == "Downloading files via HTTP..." + assert status.progress == 50.0 def test_a_vanished_torrent_becomes_an_error_rather_than_a_stall(self, monkeypatch, tmp_path): monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) @@ -394,6 +381,57 @@ class TestPolling: assert second.state == DownloadState.DOWNLOADING assert request.call_count == 1 + def test_the_rate_limit_back_off_doubles_and_stays_under_the_stall_window( + self, monkeypatch, tmp_path + ): + # The orchestrator cancels a download after five minutes without a change, + # so no single back-off may outlast that. + monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) + _mock_request(monkeypatch, {"success": False, "error": "floodDetected"}, status_code=429) + clock = [1_000_000.0] + monkeypatch.setattr(debridlink.time, "time", lambda: clock[0]) + client = _client(monkeypatch) + + waits = [] + for _ in range(4): + client.get_status("DL1") + waits.append(DebridLinkClient._flood_until - clock[0]) + clock[0] = DebridLinkClient._flood_until + 1 + + assert waits == [60.0, 120.0, 240.0, 240.0] + assert max(waits) < 300 + + def test_a_successful_check_resets_the_back_off(self, monkeypatch, tmp_path): + monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) + clock = [1_000_000.0] + monkeypatch.setattr(debridlink.time, "time", lambda: clock[0]) + client = _client(monkeypatch) + + _mock_request(monkeypatch, {"success": False, "error": "floodDetected"}, status_code=429) + client.get_status("DL1") + clock[0] = DebridLinkClient._flood_until + 1 + _mock_request( + monkeypatch, {"success": True, "value": [{"id": "DL1", "downloadPercent": 30}]} + ) + client.get_status("DL1") + + assert DebridLinkClient._flood_backoff == 0.0 + + def test_a_flood_on_another_endpoint_does_not_pause_status_checks(self, monkeypatch, tmp_path): + # floodDetected is per endpoint: hammering Test Connection must not stop polling. + monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) + _mock_request(monkeypatch, {"success": False, "error": "floodDetected"}, status_code=429) + client = _client(monkeypatch) + assert client.test_connection()[0] is False + + request = _mock_request( + monkeypatch, {"success": True, "value": [{"id": "DL1", "downloadPercent": 30}]} + ) + status = client.get_status("DL1") + + assert request.call_count == 1 + assert status.progress == 15.0 + def test_a_temporary_error_is_ridden_out_then_fails(self, monkeypatch, tmp_path): monkeypatch.setattr("shelfmark.download.clients.debridlink.TMP_DIR", tmp_path) _mock_request(monkeypatch, {"success": False, "error": "internalError"}, status_code=500) @@ -453,9 +491,9 @@ class TestFetchTorrent: assert request.call_args.kwargs["params"] == {"ids": "DL1"} - def test_the_file_list_uses_the_singular_id_so_zips_are_expanded(self, monkeypatch): - # Debrid-Link lists a many-file torrent as one ZIP under ``ids``; ``?id=`` - # returns the individual files. + def test_the_file_list_asks_by_ids_so_zips_are_expanded(self, monkeypatch): + # The plain list shows a many-file torrent as one ZIP; the docs say + # ``/seedbox/list?ids=TORRENT_ID`` lists every file. There is no ``id``. request = _mock_request( monkeypatch, { @@ -467,9 +505,16 @@ class TestFetchTorrent: files = client._fetch_file_list("DL1") - assert request.call_args.kwargs["params"] == {"id": "DL1"} + assert request.call_args.kwargs["params"] == {"ids": "DL1"} assert [f["name"] for f in files] == ["a.mp3", "b.mp3"] + def test_the_file_list_of_a_vanished_torrent_is_an_error(self, monkeypatch): + _mock_request(monkeypatch, {"success": False, "error": "badId"}, status_code=400) + client = _client(monkeypatch) + + with pytest.raises(RuntimeError, match="no longer on Debrid-Link"): + client._fetch_file_list("DL1") + class TestConnection: def test_missing_key_is_reported_without_a_call(self, monkeypatch):