mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-02 22:06:00 +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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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