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
+12
View File
@@ -190,6 +190,8 @@ class DownloadClient(ABC):
# Class attributes that subclasses must define
protocol: str
name: str
handoff_only = False
prefers_torrent_file = False
def _log_error(self, method: str, e: Exception, level: str = "error") -> str:
"""Log a client error with consistent formatting.
@@ -375,6 +377,7 @@ _CLIENTS: dict[str, list[type[DownloadClient]]] = {}
ClientType = TypeVar("ClientType", bound=DownloadClient)
_BUILTIN_CLIENT_MODULES = (
"shelfmark.download.clients.alldebrid",
"shelfmark.download.clients.blackhole",
"shelfmark.download.clients.deluge",
"shelfmark.download.clients.nzbget",
"shelfmark.download.clients.qbittorrent",
@@ -467,6 +470,15 @@ def list_configured_clients() -> list[str]:
return result
def client_prefers_torrent_file(protocol: str) -> bool:
"""Whether the active client needs a fetched .torrent file instead of a magnet."""
_ensure_builtin_clients_registered()
return any(
client_cls.prefers_torrent_file and client_cls.is_configured()
for client_cls in _CLIENTS.get(protocol, [])
)
def get_all_clients() -> dict[str, list[type[DownloadClient]]]:
"""Get all registered client classes.
+19 -12
View File
@@ -24,7 +24,7 @@ from shelfmark.download.clients import (
)
from shelfmark.download.fs import run_blocking_io
from shelfmark.download.permissions_debug import log_path_permission_context
from shelfmark.release_sources import DownloadHandler
from shelfmark.release_sources import DownloadHandler, HandoffResult
if TYPE_CHECKING:
from collections.abc import Callable
@@ -762,8 +762,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None],
) -> str | None:
"""Execute download via configured torrent/usenet client. Returns file path or None."""
) -> str | HandoffResult | None:
"""Execute download via configured torrent/usenet client."""
try:
if cancel_flag.is_set():
status_callback("cancelled", "Cancelled")
@@ -897,7 +897,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None],
) -> str | None:
) -> str | HandoffResult | None:
"""Poll the download client for progress and handle completion."""
poll_interval = self._poll_interval()
# Track consecutive "not found" errors - torrents may take time to appear in client
@@ -905,7 +905,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
max_not_found_retries = 15 # 15 retries * poll interval ~= 30s grace period
try:
result: str | None = None
result: str | HandoffResult | None = None
logger.debug("Starting poll for %s (content_type=%s)", download_id, task.content_type)
while not cancel_flag.is_set():
status = client.get_status(download_id)
@@ -1020,12 +1020,18 @@ class ExternalClientHandler(DownloadHandler, ABC):
)
return None
result = self._handle_completed_file(
source_path=source_path_obj,
protocol=protocol,
task=task,
status_callback=status_callback,
)
if getattr(client, "handoff_only", False) is True:
result = HandoffResult(
path=str(source_path_obj),
message=f"Torrent file saved to {source_path_obj}",
)
else:
result = self._handle_completed_file(
source_path=source_path_obj,
protocol=protocol,
task=task,
status_callback=status_callback,
)
except Exception as e:
logger.exception("Error during download polling")
@@ -1036,7 +1042,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
# Clean up on success
if result:
self._on_download_complete(task)
self._cleanup_refs[task.task_id] = (client, download_id, protocol)
if not isinstance(result, HandoffResult):
self._cleanup_refs[task.task_id] = (client, download_id, protocol)
return result
+113
View File
@@ -0,0 +1,113 @@
"""Blackhole download client that saves torrent files for an external watcher."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from shelfmark.core.config import config
from shelfmark.core.naming import sanitize_filename
from shelfmark.download.clients import (
DownloadClient,
DownloadState,
DownloadStatus,
register_client,
)
from shelfmark.download.clients._coercion import config_text
from shelfmark.download.clients.torrent_utils import extract_torrent_info
@register_client("torrent")
class BlackholeClient(DownloadClient):
"""Write fetched torrent files to a directory watched by another downloader."""
protocol = "torrent"
name = "blackhole"
handoff_only = True
prefers_torrent_file = True
def __init__(self) -> None:
directory = config_text(config.get("BLACKHOLE_DIRECTORY", ""))
if not directory:
msg = "BLACKHOLE_DIRECTORY is required"
raise ValueError(msg)
self._directory = Path(directory)
@staticmethod
def is_configured() -> bool:
return config_text(config.get("PROWLARR_TORRENT_CLIENT", "")) == "blackhole" and bool(
config_text(config.get("BLACKHOLE_DIRECTORY", ""))
)
def test_connection(self) -> tuple[bool, str]:
try:
self._directory.mkdir(parents=True, exist_ok=True)
except OSError as error:
return False, f"Could not create Blackhole directory: {error}"
return True, f"Blackhole directory is ready: {self._directory}"
def add_download(
self,
url: str,
name: str,
category: str | None = None,
expected_hash: str | None = None,
**kwargs: object,
) -> str:
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
if not torrent_info.torrent_data:
msg = "Blackhole requires a .torrent file; this release only provides a magnet link"
raise ValueError(msg)
self._directory.mkdir(parents=True, exist_ok=True)
filename = f"{sanitize_filename(name) or 'torrent'}.torrent"
destination = self._next_destination(filename)
file_descriptor, temporary_path = tempfile.mkstemp(
dir=self._directory,
prefix=".blackhole-",
suffix=".tmp",
)
try:
with os.fdopen(file_descriptor, "wb") as temporary_file:
temporary_file.write(torrent_info.torrent_data)
temporary_file.flush()
os.fsync(temporary_file.fileno())
Path(temporary_path).replace(destination)
except Exception:
Path(temporary_path).unlink(missing_ok=True)
raise
return str(destination)
def get_status(self, download_id: str) -> DownloadStatus:
file_path = Path(download_id)
if file_path.is_file():
return DownloadStatus(
progress=100,
state=DownloadState.COMPLETE,
message="Torrent file saved",
complete=True,
file_path=str(file_path),
)
return DownloadStatus.error("Blackhole torrent file was not created")
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
return False
def get_download_path(self, download_id: str) -> str | None:
return download_id if Path(download_id).is_file() else None
def _next_destination(self, filename: str) -> Path:
candidate = self._directory / filename
if not candidate.exists():
return candidate
stem = Path(filename).stem
suffix = Path(filename).suffix
index = 1
while True:
candidate = self._directory / f"{stem}_{index}{suffix}"
if not candidate.exists():
return candidate
index += 1
+8
View File
@@ -590,6 +590,7 @@ def prowlarr_clients_settings() -> list[SettingsField]:
options=[
{"value": "", "label": "None"},
{"value": "alldebrid", "label": "AllDebrid"},
{"value": "blackhole", "label": "Blackhole"},
{"value": "qbittorrent", "label": "qBittorrent"},
{"value": "realdebrid", "label": "Real-Debrid"},
{"value": "transmission", "label": "Transmission"},
@@ -598,6 +599,13 @@ def prowlarr_clients_settings() -> list[SettingsField]:
],
default="",
),
TextField(
key="BLACKHOLE_DIRECTORY",
label="Blackhole Directory",
description="Directory where Shelfmark saves .torrent files for another downloader",
placeholder="/blackhole",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "blackhole"},
),
# --- AllDebrid Settings ---
PasswordField(
key="ALLDEBRID_API_KEY",
+16
View File
@@ -29,6 +29,7 @@ from shelfmark.download.fs import run_blocking_io
from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path
from shelfmark.download.postprocess.router import post_process_download
from shelfmark.release_sources import (
HandoffResult,
get_handler,
get_source,
get_source_display_name,
@@ -750,6 +751,21 @@ def _download_task(task_id: str, cancel_flag: Event) -> str | None:
if not temp_path:
return None
if isinstance(temp_path, HandoffResult):
handoff_path = Path(temp_path.path)
if not run_blocking_io(handoff_path.exists):
logger.error("Handler returned non-existent handoff path: %s", handoff_path)
_capture_task_error(
task,
message=f"Download file missing: {handoff_path}",
exc_type="MissingDownloadPath",
)
return None
status_callback("complete", temp_path.message)
handler.post_process_cleanup(task, success=True)
_clear_task_error_state(task)
return str(handoff_path)
temp_file = Path(temp_path)
if not run_blocking_io(temp_file.exists):
logger.error("Handler returned non-existent path: %s", temp_path)
+11 -2
View File
@@ -368,12 +368,21 @@ class ReleaseSource(ABC):
return None
@dataclass(frozen=True)
class HandoffResult:
"""An external handoff that completed without a Shelfmark book payload."""
path: str
message: str
class DownloadHandler(ABC):
"""Interface for executing downloads.
A handler may either:
- download directly into ``TMP_DIR`` (managed by Shelfmark), or
- return a path owned by an external client (e.g. torrent/usenet).
- finish an external handoff without producing a book payload.
The orchestrator is responsible for post-processing (archive extraction, output mode
handling) and transferring files into their final destination.
@@ -386,8 +395,8 @@ class DownloadHandler(ABC):
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None],
) -> str | None:
"""Execute download and return a path to the downloaded payload."""
) -> str | HandoffResult | None:
"""Execute download and return a payload path or completed external handoff."""
def post_process_cleanup(self, task: DownloadTask, *, success: bool) -> None:
"""Run optional cleanup after orchestrator post-processing.
+25 -9
View File
@@ -9,7 +9,12 @@ if TYPE_CHECKING:
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import normalize_optional_text
from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients
from shelfmark.download.clients import (
DownloadClient,
client_prefers_torrent_file,
get_client,
list_configured_clients,
)
from shelfmark.download.clients.base_handler import (
COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS,
)
@@ -54,13 +59,15 @@ def _get_protocol(result: dict) -> str:
return "usenet"
def _get_download_url(result: dict) -> str:
def _get_download_url(result: dict, *, prefer_torrent_file: bool = False) -> str:
"""Pick the best URL to hand to a download client."""
protocol = _get_protocol(result)
magnet_url = str(result.get("magnetUrl") or "").strip()
download_url = str(result.get("downloadUrl") or "").strip()
if protocol == "torrent":
if prefer_torrent_file:
return download_url or magnet_url
return magnet_url or download_url
return download_url or magnet_url
@@ -93,9 +100,15 @@ class NewznabHandler(ExternalClientHandler):
if result is None:
return {}
protocol = _get_protocol(result)
return {
"retry_download_url": normalize_optional_text(_get_download_url(result)),
"retry_download_protocol": normalize_optional_text(_get_protocol(result)),
"retry_download_url": normalize_optional_text(
_get_download_url(
result,
prefer_torrent_file=client_prefers_torrent_file(protocol),
)
),
"retry_download_protocol": normalize_optional_text(protocol),
}
@classmethod
@@ -137,16 +150,19 @@ class NewznabHandler(ExternalClientHandler):
status_callback("error", "Release not found in cache (may have expired)")
return None
download_url = _get_download_url(result)
if not download_url:
status_callback("error", "No download URL available")
return None
protocol = _get_protocol(result)
if protocol not in ("torrent", "usenet"):
status_callback("error", "Could not determine download protocol")
return None
download_url = _get_download_url(
result,
prefer_torrent_file=client_prefers_torrent_file(protocol),
)
if not download_url:
status_callback("error", "No download URL available")
return None
release_name = result.get("title") or task.title or "Unknown"
expected_hash = str(result.get("infoHash") or "").strip() or None
@@ -12,6 +12,7 @@ from shelfmark.core.search_plan import build_release_search_plan
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.clients import (
DownloadClient,
client_prefers_torrent_file,
get_client,
list_configured_clients,
)
@@ -247,18 +248,20 @@ class ProwlarrHandler(ExternalClientHandler):
status_callback("error", EXPIRED_LINK_REFRESH_ERROR)
return None
# Extract download URL
download_url = get_preferred_download_url(prowlarr_result)
if not download_url:
status_callback("error", "No download URL available")
return None
# Determine protocol
protocol = get_protocol(prowlarr_result)
if protocol == "unknown":
status_callback("error", "Could not determine download protocol")
return None
download_url = get_preferred_download_url(
prowlarr_result,
prefer_torrent_file=client_prefers_torrent_file(protocol),
)
if not download_url:
status_callback("error", "No download URL available")
return None
release_name = prowlarr_result.get("title") or task.title or "Unknown"
expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None
+5 -3
View File
@@ -149,17 +149,19 @@ def get_protocol(result: dict) -> str:
return "unknown"
def get_preferred_download_url(result: dict) -> str:
def get_preferred_download_url(result: dict, *, prefer_torrent_file: bool = False) -> str:
"""Pick the best URL to hand to a download client.
For torrent results, prefer magnetUrl when available (downloadUrl may be a
Prowlarr proxy URL that needs auth/headers).
For torrent results, prefer magnetUrl when available unless the configured
client needs the fetched .torrent bytes.
"""
protocol = str(result.get("protocol", "")).lower()
magnet_url = str(result.get("magnetUrl") or "").strip()
download_url = sanitize_download_url(str(result.get("downloadUrl") or "").strip())
if protocol == "torrent":
if prefer_torrent_file:
return download_url or magnet_url
return magnet_url or download_url
if protocol == "usenet":
return download_url or magnet_url
@@ -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()."""