Fix: Use info hash for clients (#495)

Passes prowlarr's info hash to download client, existing behavior as
fallback
This commit is contained in:
Alex
2026-01-19 20:23:45 +00:00
committed by GitHub
parent 8470095534
commit a030bca5d3
13 changed files with 227 additions and 25 deletions

View File

@@ -18,7 +18,7 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from functools import wraps
from typing import Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union
from typing import Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union, cast, Any
import requests
@@ -87,7 +87,9 @@ def with_retry(
time.sleep(delay)
# All retries exhausted
raise last_exception
if last_exception is None:
raise RuntimeError("Retry failed without exception")
raise cast(Exception, last_exception)
return wrapper
return decorator
@@ -256,13 +258,22 @@ class DownloadClient(ABC):
pass
@abstractmethod
def add_download(self, url: str, name: str, category: Optional[str] = None) -> str:
def add_download(
self,
url: str,
name: str,
category: Optional[str] = None,
expected_hash: Optional[str] = None,
**kwargs: Any,
) -> str:
"""Add a download to the client.
Args:
url: Download URL (magnet link, .torrent URL, or NZB URL)
name: Display name for the download
category: Category/label for organization (None = client default)
expected_hash: Optional info_hash hint (torrents only)
Returns:
Client-specific download ID (hash for torrents, ID for NZBGet).

View File

@@ -202,13 +202,20 @@ class DelugeClient(DownloadClient):
self._connected = False
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: Optional[str] = None) -> str:
def add_download(
self,
url: str,
name: str,
category: Optional[str] = None,
expected_hash: Optional[str] = None,
**kwargs,
) -> str:
try:
self._ensure_connected()
category_value = str(category or self._category)
torrent_info = extract_torrent_info(url)
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
if not torrent_info.is_magnet and not torrent_info.torrent_data:
raise Exception("Failed to fetch torrent file")

View File

@@ -101,7 +101,14 @@ class NZBGetClient(DownloadClient):
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: Optional[str] = None) -> str:
def add_download(
self,
url: str,
name: str,
category: Optional[str] = None,
expected_hash: Optional[str] = None,
**kwargs,
) -> str:
"""
Add NZB by URL.
@@ -112,6 +119,7 @@ class NZBGetClient(DownloadClient):
url: NZB URL (can be Prowlarr proxy URL)
name: Display name for the download
category: Category for organization (uses configured default if not specified)
expected_hash: Optional info_hash hint (unused)
Returns:
NZBGet download ID (NZBID).

View File

@@ -229,7 +229,14 @@ class QBittorrentClient(DownloadClient):
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: str | None = None) -> str:
def add_download(
self,
url: str,
name: str,
category: str | None = None,
expected_hash: str | None = None,
**kwargs,
) -> str:
"""
Add torrent by URL (magnet or .torrent).
@@ -237,6 +244,7 @@ class QBittorrentClient(DownloadClient):
url: Magnet link or .torrent URL
name: Display name for the torrent
category: Category for organization (uses configured default if not specified)
expected_hash: Optional info_hash hint (from Prowlarr)
Returns:
Torrent hash (info_hash).
@@ -257,7 +265,7 @@ class QBittorrentClient(DownloadClient):
if "Conflict" not in type(e).__name__ and "409" not in str(e):
logger.debug(f"Could not create category '{category}': {type(e).__name__}: {e}")
torrent_info = extract_torrent_info(url)
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
expected_hash = torrent_info.info_hash
torrent_data = torrent_info.torrent_data

View File

@@ -69,7 +69,14 @@ class RTorrentClient(DownloadClient):
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: Optional[str] = None) -> str:
def add_download(
self,
url: str,
name: str,
category: Optional[str] = None,
expected_hash: Optional[str] = None,
**kwargs,
) -> str:
"""
Add torrent by URL (magnet or .torrent).
@@ -77,6 +84,7 @@ class RTorrentClient(DownloadClient):
url: Magnet link or .torrent URL
name: Display name for the torrent
category: Category for organization (uses configured label if not specified)
expected_hash: Optional info_hash hint (from Prowlarr)
Returns:
Torrent hash (info_hash).
@@ -85,7 +93,7 @@ class RTorrentClient(DownloadClient):
Exception: If adding fails.
"""
try:
torrent_info = extract_torrent_info(url)
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
commands = []
@@ -109,7 +117,7 @@ class RTorrentClient(DownloadClient):
add_url = torrent_info.magnet_url or url
self._rpc.load.start("", add_url, ";".join(commands))
torrent_hash = torrent_info.info_hash
torrent_hash = torrent_info.info_hash or expected_hash
if not torrent_hash:
raise Exception("Could not determine torrent hash from URL")

View File

@@ -172,7 +172,14 @@ class SABnzbdClient(DownloadClient):
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: Optional[str] = None) -> str:
def add_download(
self,
url: str,
name: str,
category: Optional[str] = None,
expected_hash: Optional[str] = None,
**kwargs,
) -> str:
"""
Add NZB by URL.
@@ -180,6 +187,7 @@ class SABnzbdClient(DownloadClient):
url: NZB URL (can be Prowlarr proxy URL)
name: Display name for the download
category: Category for organization (uses configured default if not specified)
expected_hash: Optional info_hash hint (unused)
Returns:
SABnzbd nzo_id.

View File

@@ -31,8 +31,23 @@ class TorrentInfo:
magnet_url: Optional[str] = None
"""The actual magnet URL, if available."""
def with_info_hash(self, info_hash: Optional[str]) -> "TorrentInfo":
"""Return a copy with the info_hash replaced when provided."""
if info_hash:
return TorrentInfo(
info_hash=info_hash,
torrent_data=self.torrent_data,
is_magnet=self.is_magnet,
magnet_url=self.magnet_url,
)
return self
def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo:
def extract_torrent_info(
url: str,
fetch_torrent: bool = True,
expected_hash: Optional[str] = None,
) -> TorrentInfo:
"""Extract info_hash from magnet link or .torrent URL.
Notes:
@@ -48,12 +63,80 @@ def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo:
# Try to extract hash from magnet URL
if is_magnet:
info_hash = extract_hash_from_magnet(url)
if not info_hash and expected_hash:
info_hash = expected_hash
return TorrentInfo(info_hash=info_hash, torrent_data=None, is_magnet=True, magnet_url=url)
# Not a magnet - try to fetch and parse the .torrent file
if expected_hash:
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
if not fetch_torrent:
return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False)
headers: dict[str, str] = {}
api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip()
if api_key:
headers["X-Api-Key"] = api_key
def resolve_url(current: str, location: str) -> str:
if not location:
return current
# Support relative redirect locations
return urljoin(current, location)
try:
logger.debug(f"Fetching torrent file from: {url[:80]}...")
# Use allow_redirects=False to handle magnet link redirects manually
# Some indexers redirect download URLs to magnet links
resp = requests.get(url, timeout=30, allow_redirects=False, headers=headers)
# Check if this is a redirect to a magnet link
if resp.status_code in (301, 302, 303, 307, 308):
redirect_url = resolve_url(url, resp.headers.get("Location", ""))
if redirect_url.startswith("magnet:"):
logger.debug("Download URL redirected to magnet link")
info_hash = extract_hash_from_magnet(redirect_url)
if not info_hash and expected_hash:
info_hash = expected_hash
return TorrentInfo(
info_hash=info_hash, torrent_data=None, is_magnet=True, magnet_url=redirect_url
)
# Not a magnet redirect, follow it manually
logger.debug(f"Following redirect to: {redirect_url[:80]}...")
resp = requests.get(redirect_url, timeout=30, headers=headers)
resp.raise_for_status()
torrent_data = resp.content
# Check if response is actually a magnet link (text response)
# Some indexers return magnet links as plain text instead of redirecting
if len(torrent_data) < 2000: # Magnet links are typically short
try:
text_content = torrent_data.decode("utf-8", errors="ignore").strip()
if text_content.startswith("magnet:"):
logger.debug("Download URL returned magnet link as response body")
info_hash = extract_hash_from_magnet(text_content)
if not info_hash and expected_hash:
info_hash = expected_hash
return TorrentInfo(
info_hash=info_hash, torrent_data=None, is_magnet=True, magnet_url=text_content
)
except Exception:
pass # Not text, continue with torrent parsing
info_hash = extract_info_hash_from_torrent(torrent_data)
if info_hash:
logger.debug(f"Extracted hash from torrent file: {info_hash}")
else:
logger.warning("Could not extract hash from torrent file")
return TorrentInfo(info_hash=info_hash, torrent_data=torrent_data, is_magnet=False)
except Exception as e:
logger.debug(f"Could not fetch torrent file: {e}")
return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False)
headers: dict[str, str] = {}
api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip()
if api_key:

View File

@@ -73,7 +73,14 @@ class TransmissionClient(DownloadClient):
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: Optional[str] = None) -> str:
def add_download(
self,
url: str,
name: str,
category: Optional[str] = None,
expected_hash: Optional[str] = None,
**kwargs,
) -> str:
"""
Add torrent by URL (magnet or .torrent).
@@ -81,6 +88,7 @@ class TransmissionClient(DownloadClient):
url: Magnet link or .torrent URL
name: Display name for the torrent
category: Category for organization (uses configured default if not specified)
expected_hash: Optional info_hash hint (from Prowlarr)
Returns:
Torrent hash (info_hash).
@@ -91,7 +99,7 @@ class TransmissionClient(DownloadClient):
try:
resolved_category = category or self._category or ""
torrent_info = extract_torrent_info(url)
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
if torrent_info.torrent_data:
torrent = self._client.add_torrent(

View File

@@ -300,10 +300,12 @@ class ProwlarrHandler(DownloadHandler):
try:
release_name = prowlarr_result.get("title") or task.title or "Unknown"
category = self._get_category_for_task(client, task)
expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None
download_id = client.add_download(
url=download_url,
name=release_name,
category=category,
expected_hash=expected_hash,
)
except Exception as e:
logger.error(f"Failed to add to {client.name}: {e}")

View File

@@ -151,7 +151,7 @@ class TestDownloadStatus:
file_path=None,
)
with pytest.raises(AttributeError):
status.progress = 75.0
setattr(status, "progress", 75.0)
def test_download_status_state_value_with_unknown_string(self):
"""Test state_value with an unknown state string."""
@@ -212,7 +212,7 @@ class TestClientRegistry:
def test_connection(self):
return True, "OK"
def add_download(self, url, name, category="test"):
def add_download(self, url, name, category=None, expected_hash=None, **kwargs):
return "id"
def get_status(self, download_id):
@@ -245,7 +245,7 @@ class TestClientRegistry:
def test_connection(self):
return True, "OK"
def add_download(self, url, name, category="test"):
def add_download(self, url, name, category=None, expected_hash=None, **kwargs):
return "id"
def get_status(self, download_id):
@@ -269,7 +269,7 @@ class TestClientRegistry:
def test_connection(self):
return True, "OK"
def add_download(self, url, name, category="test"):
def add_download(self, url, name, category=None, expected_hash=None, **kwargs):
return "id"
def get_status(self, download_id):
@@ -301,7 +301,7 @@ class TestClientRegistry:
def test_connection(self):
return True, "OK"
def add_download(self, url, name, category="test"):
def add_download(self, url, name, category=None, expected_hash=None, **kwargs):
return "id"
def get_status(self, download_id):
@@ -325,7 +325,7 @@ class TestClientRegistry:
def test_connection(self):
return True, "OK"
def add_download(self, url, name, category="test"):
def add_download(self, url, name, category=None, expected_hash=None, **kwargs):
return "id"
def get_status(self, download_id):
@@ -377,7 +377,7 @@ class TestDownloadClientInterface:
def test_connection(self):
return True, "OK"
def add_download(self, url, name, category="test"):
def add_download(self, url, name, category=None, expected_hash=None, **kwargs):
return "id"
def get_status(self, download_id):

View File

@@ -82,7 +82,14 @@ class MockClient(DownloadClient):
def test_connection(self) -> Tuple[bool, str]:
return True, "Mock client connected"
def add_download(self, url: str, name: str, category: str = "cwabd") -> str:
def add_download(
self,
url: str,
name: str,
category: Optional[str] = None,
expected_hash: Optional[str] = None,
**kwargs,
) -> str:
if self.add_download_error:
raise self.add_download_error
download_id = f"mock-{len(self.downloads)}"

View File

@@ -395,8 +395,9 @@ class TestQBittorrentIntegration:
assert 0 <= status.progress <= 100
valid_states = {"downloading", "complete", "error", "seeding", "paused", "queued", "fetching_metadata", "stalled"}
assert status.state.value in valid_states
valid_states = {"downloading", "complete", "error", "seeding", "paused", "queued", "fetching_metadata", "stalled", "checking"}
state_value = status.state.value if hasattr(status.state, "value") else status.state
assert state_value in valid_states
assert isinstance(status.complete, bool)
finally:

View File

@@ -10,6 +10,7 @@ from unittest.mock import MagicMock, patch
import pytest
from shelfmark.release_sources.prowlarr.clients import DownloadStatus
from shelfmark.release_sources.prowlarr.clients.torrent_utils import TorrentInfo
class MockTorrent:
@@ -465,6 +466,56 @@ class TestQBittorrentClientAddDownload:
assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
assert mock_client_instance._session.get.call_count >= 1
def test_add_download_uses_expected_hash_without_fetch(self, monkeypatch):
"""Skip proxy fetch when expected hash is provided for URL torrents."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "test",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
expected_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
mock_torrent = MockTorrent(hash_val=expected_hash)
mock_client_instance = MagicMock()
mock_client_instance.torrents_add.return_value = "Ok."
mock_client_instance.torrents_info.return_value = [mock_torrent]
mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.release_sources.prowlarr.clients.qbittorrent as qb_module
importlib.reload(qb_module)
with patch(
"shelfmark.release_sources.prowlarr.clients.qbittorrent.extract_torrent_info",
autospec=True,
) as mock_extract:
mock_extract.return_value = TorrentInfo(
info_hash=expected_hash,
torrent_data=None,
is_magnet=False,
magnet_url=None,
)
client = qb_module.QBittorrentClient()
result = client.add_download(
"http://example.com/test.torrent",
"Test Download",
expected_hash=expected_hash,
)
assert result == expected_hash
mock_extract.assert_called_once_with(
"http://example.com/test.torrent",
expected_hash=expected_hash,
)
def test_add_download_creates_category(self, monkeypatch):
"""Test that add_download creates category if needed."""
config_values = {