From dce91e69726d69cf024ba705af7720345a4fd453 Mon Sep 17 00:00:00 2001 From: CaliBrain Date: Fri, 25 Sep 2026 19:11:56 -0400 Subject: [PATCH] fix(audiobookbay): reuse the resolved magnet when retrying (#1388) (#1398) Retrying an AudiobookBay download re-scraped the detail page for the magnet link before checking the torrent client, so a torrent that had already finished in the client could not be imported while AudiobookBay was down. The handler now keeps the magnet it resolved in the task's retry context, which is persisted with the download history, and a retry hands that magnet straight to the client's existing-download check. The context is handler-owned, so a client-supplied download URL cannot seed it. Fixes #1388 --- .../release_sources/audiobookbay/handler.py | 17 +++ tests/audiobookbay/test_handler.py | 105 ++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/shelfmark/release_sources/audiobookbay/handler.py b/shelfmark/release_sources/audiobookbay/handler.py index f080ec9f..a689cb7d 100644 --- a/shelfmark/release_sources/audiobookbay/handler.py +++ b/shelfmark/release_sources/audiobookbay/handler.py @@ -27,6 +27,10 @@ if TYPE_CHECKING: logger = setup_logger(__name__) DEFAULT_ABB_HOSTNAME = "audiobookbay.lu" ALLOWED_DETAIL_URL_SCHEMES = {"https"} +# retry_source_context key for the magnet resolved on an earlier attempt. It lives in the +# handler-owned context rather than retry_download_url, which queue time fills from the +# client-supplied release data. +MAGNET_LINK_CONTEXT_KEY = "magnet_link" def _resolve_configured_hostname() -> str: @@ -96,6 +100,18 @@ class AudiobookBayHandler(ExternalClientHandler): status_callback: Callable[[str, str | None], None], ) -> DownloadRequest | None: """Resolve ABB detail page into a magnet-link download request.""" + # A retry must not depend on AudiobookBay being reachable: the torrent may already + # be finished in the client, and the magnet cannot go stale (#1388). + 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) + return DownloadRequest( + url=cached_magnet, + protocol="torrent", + release_name=task.title or "Unknown", + expected_hash=None, + ) + detail_url = self._resolve_detail_url(task) if not detail_url: status_callback("error", "Missing AudiobookBay details URL") @@ -119,6 +135,7 @@ class AudiobookBayHandler(ExternalClientHandler): return None logger.info("Extracted magnet link for task %s", task.task_id) + task.retry_source_context[MAGNET_LINK_CONTEXT_KEY] = magnet_link return DownloadRequest( url=magnet_link, diff --git a/tests/audiobookbay/test_handler.py b/tests/audiobookbay/test_handler.py index 8fdd1afe..31473e5d 100644 --- a/tests/audiobookbay/test_handler.py +++ b/tests/audiobookbay/test_handler.py @@ -6,11 +6,17 @@ from pathlib import Path from threading import Event from unittest.mock import MagicMock, patch +import pytest + from shelfmark.core.models import DownloadTask from shelfmark.download.clients import ( DownloadState, DownloadStatus, ) +from shelfmark.download.orchestrator import ( + _restore_task_from_retry_payload, + serialize_task_for_retry, +) from shelfmark.release_sources.audiobookbay.handler import AudiobookBayHandler @@ -243,6 +249,105 @@ class TestAudiobookBayHandlerDownload: assert result == "/path/to/book.m4b" mock_client.add_download.assert_not_called() + @pytest.mark.parametrize("restarted", [False, True], ids=["in_memory", "after_restart"]) + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + @patch("shelfmark.release_sources.audiobookbay.handler.get_client") + def test_retry_imports_finished_torrent_while_site_is_down( + self, mock_get_client, mock_extract_magnet, restarted + ): + """A retry reuses the first attempt's magnet instead of re-scraping AudiobookBay (#1388).""" + magnet = "magnet:?xt=urn:btih:abc123" + mock_extract_magnet.return_value = magnet + + mock_client = MagicMock() + mock_client.name = "qbittorrent" + mock_client.find_existing.return_value = None + mock_client.add_download.return_value = "abc123" + mock_get_client.return_value = mock_client + + handler = AudiobookBayHandler() + task = DownloadTask( + task_id="35f56a3e5734bfa69c3169ee8e605a60", + source="audiobookbay", + title="Test Book", + content_type="audiobook", + source_url="https://audiobookbay.lu/abss/test-book/", + ) + recorder = ProgressRecorder() + + # First attempt hands the magnet to the client, then gives up (e.g. stall timeout). + with patch.object(AudiobookBayHandler, "_poll_and_complete", return_value=None): + handler.download( + task=task, + cancel_flag=Event(), + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + mock_client.add_download.assert_called_once() + + if restarted: + task = _restore_task_from_retry_payload(serialize_task_for_retry(task)) + assert task is not None + + # AudiobookBay goes down while the torrent finishes in the client. + mock_extract_magnet.reset_mock() + mock_extract_magnet.return_value = None + mock_client.find_existing.return_value = ( + "abc123", + DownloadStatus( + progress=100, + state=DownloadState.COMPLETE, + message="Complete", + complete=True, + file_path="/path/to/book.m4b", + ), + ) + + with patch.object( + AudiobookBayHandler, + "_wait_for_completed_path", + return_value=(Path("/path/to/book.m4b"), None), + ): + result = handler.download( + task=task, + cancel_flag=Event(), + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result == "/path/to/book.m4b" + mock_extract_magnet.assert_not_called() + assert mock_client.find_existing.call_args.args[0] == magnet + mock_client.add_download.assert_called_once() + + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") + @patch("shelfmark.release_sources.audiobookbay.handler.get_client") + def test_failed_scrape_caches_no_magnet(self, mock_get_client, mock_extract_magnet): + """Only a magnet that was actually resolved is reused, so a retry scrapes again.""" + mock_extract_magnet.return_value = None + + handler = AudiobookBayHandler() + task = DownloadTask( + task_id="35f56a3e5734bfa69c3169ee8e605a60", + source="audiobookbay", + title="Test Book", + content_type="audiobook", + source_url="https://audiobookbay.lu/abss/test-book/", + ) + recorder = ProgressRecorder() + + for _ in range(2): + handler.download( + task=task, + cancel_flag=Event(), + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert mock_extract_magnet.call_count == 2 + assert task.retry_source_context == {} + mock_get_client.assert_not_called() + @patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link") @patch("shelfmark.release_sources.audiobookbay.handler.get_client") def test_download_existing_in_progress(self, mock_get_client, mock_extract_magnet):