Skip to content
Merged
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
17 changes: 17 additions & 0 deletions shelfmark/release_sources/audiobookbay/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
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:
Expand Down Expand Up @@ -96,6 +100,18 @@ def _resolve_download(
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")
Expand All @@ -119,6 +135,7 @@ def _resolve_download(
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,
Expand Down
105 changes: 105 additions & 0 deletions tests/audiobookbay/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@
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


Expand Down Expand Up @@ -243,6 +249,105 @@ def test_download_existing_complete(self, mock_get_client, mock_extract_magnet):
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):
Expand Down
Loading