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:
Atirna
2026-09-11 00:46:47 -04:00
committed by GitHub
parent 96d1b7c33a
commit 265da07d7f
13 changed files with 347 additions and 33 deletions
@@ -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)
+11
View File
@@ -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",
+73
View File
@@ -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()) == []
+15 -1
View File
@@ -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()."""