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):