mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
feat(download): add Blackhole torrent handoff (#1312)
## Why Blackhole users need Shelfmark to hand a torrent file to their existing downloader instead of importing the downloaded book itself. ## Change - add Blackhole as a torrent client with a configurable watched directory - prefer a fetched `.torrent` file for Blackhole while preserving magnet preference for other clients - complete the queue task after the handoff without invoking book post-processing ## Verification - `uv run pytest -q tests/prowlarr/test_blackhole_client.py tests/prowlarr/test_handler.py tests/newznab/test_handler.py tests/download/test_orchestrator_lifecycle.py` - `uv run basedpyright shelfmark/download/clients/blackhole.py shelfmark/download/clients/__init__.py shelfmark/download/clients/base_handler.py shelfmark/download/clients/settings.py shelfmark/download/orchestrator.py shelfmark/release_sources/__init__.py shelfmark/release_sources/prowlarr/utils.py shelfmark/release_sources/prowlarr/handler.py shelfmark/release_sources/newznab/handler.py tests/prowlarr/test_blackhole_client.py tests/prowlarr/test_handler.py tests/newznab/test_handler.py tests/download/test_orchestrator_lifecycle.py` Fixes #1229
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from threading import Event
|
||||
from unittest.mock import ANY, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.release_sources import HandoffResult
|
||||
|
||||
|
||||
class _StopLoop(BaseException):
|
||||
"""Sentinel used to stop the infinite coordinator loop during tests."""
|
||||
@@ -225,3 +229,29 @@ def test_start_replaces_dead_coordinator_thread(monkeypatch):
|
||||
)
|
||||
new_thread.start.assert_called_once_with()
|
||||
assert orchestrator._coordinator_thread is new_thread
|
||||
|
||||
|
||||
def test_download_task_completes_blackhole_handoff_without_post_processing(monkeypatch, tmp_path):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
handoff_file = tmp_path / "release.torrent"
|
||||
handoff_file.write_bytes(b"torrent-bytes")
|
||||
task = DownloadTask(task_id="blackhole-task", source="prowlarr", title="Book")
|
||||
queue = MagicMock()
|
||||
queue.get_task.return_value = task
|
||||
handler = MagicMock()
|
||||
handler.download.return_value = HandoffResult(
|
||||
path=str(handoff_file),
|
||||
message=f"Torrent file saved to {handoff_file}",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(orchestrator, "book_queue", queue)
|
||||
monkeypatch.setattr(orchestrator, "get_handler", lambda _source: handler)
|
||||
monkeypatch.setattr(orchestrator, "_source_unavailable_message", lambda _source: None)
|
||||
monkeypatch.setattr(orchestrator, "post_process_download", MagicMock())
|
||||
|
||||
result = orchestrator._download_task(task.task_id, Event())
|
||||
|
||||
assert result == str(handoff_file)
|
||||
orchestrator.post_process_download.assert_not_called()
|
||||
handler.post_process_cleanup.assert_called_once_with(task, success=True)
|
||||
|
||||
@@ -84,6 +84,17 @@ class TestGetDownloadUrl:
|
||||
}
|
||||
assert _get_download_url(result) == "magnet:?xt=urn:btih:abc"
|
||||
|
||||
def test_blackhole_prefers_torrent_file(self):
|
||||
result = {
|
||||
"protocol": "torrent",
|
||||
"downloadUrl": "https://example.com/file.torrent",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc",
|
||||
}
|
||||
assert (
|
||||
_get_download_url(result, prefer_torrent_file=True)
|
||||
== "https://example.com/file.torrent"
|
||||
)
|
||||
|
||||
def test_falls_back_to_download_url_when_no_magnet(self):
|
||||
result = {
|
||||
"protocol": "torrent",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Unit tests for the Blackhole torrent-file handoff client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.download.clients.torrent_utils import TorrentInfo
|
||||
|
||||
|
||||
def make_config_getter(values: dict[str, str]):
|
||||
def getter(key: str, default: str = "") -> str:
|
||||
return values.get(key, default)
|
||||
|
||||
return getter
|
||||
|
||||
|
||||
def test_blackhole_saves_torrent_file_and_reports_completed_handoff(monkeypatch, tmp_path):
|
||||
from shelfmark.download.clients.blackhole import BlackholeClient
|
||||
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.blackhole.config.get",
|
||||
make_config_getter(
|
||||
{
|
||||
"PROWLARR_TORRENT_CLIENT": "blackhole",
|
||||
"BLACKHOLE_DIRECTORY": str(tmp_path),
|
||||
}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.blackhole.extract_torrent_info",
|
||||
lambda *_args, **_kwargs: TorrentInfo(
|
||||
info_hash="abc123",
|
||||
torrent_data=b"torrent-bytes",
|
||||
is_magnet=False,
|
||||
),
|
||||
)
|
||||
|
||||
client = BlackholeClient()
|
||||
download_id = client.add_download(
|
||||
"https://indexer.example/release.torrent",
|
||||
"A Test Book",
|
||||
)
|
||||
|
||||
saved_file = tmp_path / "A Test Book.torrent"
|
||||
assert download_id == str(saved_file)
|
||||
assert saved_file.read_bytes() == b"torrent-bytes"
|
||||
assert client.get_status(download_id).complete is True
|
||||
assert client.get_download_path(download_id) == str(saved_file)
|
||||
|
||||
|
||||
def test_blackhole_rejects_magnet_only_release(monkeypatch, tmp_path):
|
||||
from shelfmark.download.clients.blackhole import BlackholeClient
|
||||
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.blackhole.config.get",
|
||||
make_config_getter({"BLACKHOLE_DIRECTORY": str(tmp_path)}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.blackhole.extract_torrent_info",
|
||||
lambda *_args, **_kwargs: TorrentInfo(
|
||||
info_hash="abc123",
|
||||
torrent_data=None,
|
||||
is_magnet=True,
|
||||
magnet_url="magnet:?xt=urn:btih:abc123",
|
||||
),
|
||||
)
|
||||
|
||||
client = BlackholeClient()
|
||||
|
||||
with pytest.raises(ValueError, match="requires a .torrent file"):
|
||||
client.add_download("magnet:?xt=urn:btih:abc123", "A Test Book")
|
||||
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
@@ -18,7 +18,11 @@ from shelfmark.download.clients import (
|
||||
from shelfmark.release_sources import Release, ReleaseProtocol
|
||||
from shelfmark.release_sources.prowlarr.cache import cache_release, remove_release
|
||||
from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler
|
||||
from shelfmark.release_sources.prowlarr.utils import build_source_id, get_protocol
|
||||
from shelfmark.release_sources.prowlarr.utils import (
|
||||
build_source_id,
|
||||
get_preferred_download_url,
|
||||
get_protocol,
|
||||
)
|
||||
|
||||
|
||||
class ProgressRecorder:
|
||||
@@ -77,6 +81,16 @@ class TestGetProtocol:
|
||||
assert get_protocol({"protocol": "USENET"}) == "usenet"
|
||||
|
||||
|
||||
def test_blackhole_prefers_torrent_file_over_magnet():
|
||||
result = {
|
||||
"protocol": "torrent",
|
||||
"downloadUrl": "https://prowlarr.example/download/123",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
||||
}
|
||||
|
||||
assert get_preferred_download_url(result, prefer_torrent_file=True) == result["downloadUrl"]
|
||||
|
||||
|
||||
class TestProwlarrHandlerDownloadErrors:
|
||||
"""Tests for error handling in ProwlarrHandler.download()."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user