mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-03 19:15:49 +01:00
Fix issue with stale v1 hash queries to qbittorrent (#1162)
## Problem
Shelfmark can lose track of hybrid v1/v2 torrents after qBittorrent
completes their metadata download.
Shelfmark initially identifies the torrent by its v1 infohash. Once
metadata resolves, qBittorrent may switch the torrent’s primary `hash`
to the truncated v2 hash, causing lookups using the original v1 hash to
return nothing.
For example:
- v1: `edf46c7f938a3c678081734d7bff8b9c652ba5e5`
- qBittorrent `hash`: `0bed5f40753b342cb143e83c2b21924cc8474731`
- full v2:
`0bed5f40753b342cb143e83c2b21924cc847473134e44d1bd300bdc58c13010f`
At that point, querying `/api/v2/torrents/info` with the original v1
hash returns no records. Querying with the new primary hash works, and
the returned record still contains the original hash in `infohash_v1`.
`find_existing()` also ignored its provided category, so audiobook
torrents fall back to the default ebook category instead. This means the
fallback method for a mismatched download ID never occurs for
audiobooks, leading to a "failed" download that is actually successful
in qBittorrent. As a result, the downloaded files are not automatically
transferred/hardlinked to the output directory.
## Fix
- Match torrents against `hash`, `infohash_v1`, and `infohash_v2`.
- Wait for magnet metadata to finish downloading before returning the
torrent ID.
- Return qBittorrent’s current primary `hash`.
- Search the provided category first, then the configured default, and
finally the full torrent list.
Logs showing the issue:
```
2026-08-03 22:42:30,262 - shelfmark.release_sources.audiobookbay.scraper - DEBUG - scraper.py:450 - Generated Magnet Link: magnet:?xt=urn:btih:EDF46C7F938A3C678081734D7BFF8B9C652BA5E5&tr=...
2026-08-03 22:42:30,376 - shelfmark.download.clients.qbittorrent - DEBUG - qbittorrent.py:521 - qBittorrent add result: TorrentsAddedMetadata({'added_torrent_ids': ['edf46c7f938a3c678081734d7bff8b9c652ba5e5'], 'failure_count': 0, 'pending_count': 0, 'success_count': 1})
2026-08-03 22:42:30,427 - shelfmark.download.clients.qbittorrent - INFO - qbittorrent.py:545 - Added torrent: edf46c7f938a3c678081734d7bff8b9c652ba5e5
2026-08-03 22:42:30,427 - shelfmark.download.clients.base_handler - INFO - base_handler.py:868 - Added to qbittorrent: edf46c7f938a3c678081734d7bff8b9c652ba5e5 for 'Pathogenesis: A History of the World in Eight Plagues'
2026-08-03 22:42:30,427 - shelfmark.download.clients.base_handler - DEBUG - base_handler.py:906 - Starting poll for edf46c7f938a3c678081734d7bff8b9c652ba5e5 (content_type=audiobook)
2026-08-03 22:42:32,590 - shelfmark.download.clients.base_handler - DEBUG - base_handler.py:958 - Download edf46c7f938a3c678081734d7bff8b9c652ba5e5 not yet visible in client (attempt 1/15)
2026-08-03 22:43:02,345 - shelfmark.download.clients.base_handler - ERROR - base_handler.py:969 - Download edf46c7f938a3c678081734d7bff8b9c652ba5e5 not found after 15 attempts
2026-08-03 22:43:02,345 - shelfmark.download.clients.base_handler - INFO - base_handler.py:426 - Skipping download client cleanup for protocol=torrent after download error (client=qbittorrent id=edf46c7f938a3c678081734d7bff8b9c652ba5e5)
```
<br>
The successful torrent:
<br>
<img width="968" height="159" alt="image"
src="https://github.com/user-attachments/assets/d63c4444-6a4d-4214-97a0-732acc338970"
/>
<br>
v1 vs v2 hash:
<br>
<img width="749" height="212" alt="image"
src="https://github.com/user-attachments/assets/d985b78a-84fe-4a26-9697-126c787e7303"
/>
I ran some python queries from the shelfmark container that show the
mismatch:
```
qBittorrent URL: http://gluetun-mam:8081
Tracked hash: edf46c7f938a3c678081734d7bff8b9c652ba5e5
=== PROPERTIES LOOKUP USING SHELFMARK HASH ===
HTTP status: 404
Not Found
=== EXACT /torrents/info HASH LOOKUP ===
Returned torrents: 0
=== FIND VISIBLE PATHOGENESIS TORRENT ===
Matching visible torrents: 1
Name: Pathogenesis: A History of the World in Eight Plagues
Primary hash: 0bed5f40753b342cb143e83c2b21924cc8474731
Category: audiobooks
State: stalledUP
Progress: 1
Properties HTTP status: 200
Infohash v1: edf46c7f938a3c678081734d7bff8b9c652ba5e5
Infohash v2: 0bed5f40753b342cb143e83c2b21924cc847473134e44d1bd300bdc58c13010f
```
This commit is contained in:
@@ -44,6 +44,7 @@ _HASH_LENGTH_40 = 40
|
||||
_HASH_LENGTH_ED2K = 32
|
||||
_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN
|
||||
_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
_METADATA_DOWNLOAD_STATES = {"forcedMetaDL", "metaDL"}
|
||||
_ONE_WEEK_IN_SECONDS = 604800
|
||||
|
||||
|
||||
@@ -94,6 +95,25 @@ def _hashes_match(hash1: str, hash2: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _torrent_matches_download_id(torrent: object, download_id: str) -> bool:
|
||||
"""Match an ID against every identity qBittorrent exposes.
|
||||
|
||||
For hybrid torrents, qBittorrent's primary `hash` can change from the v1
|
||||
hash to the truncated v2 hash after metadata resolution. The full
|
||||
`infohash_v1` and `infohash_v2` fields preserve the torrent's identities.
|
||||
"""
|
||||
identifiers = (
|
||||
getattr(torrent, "hash", None),
|
||||
getattr(torrent, "infohash_v1", None),
|
||||
getattr(torrent, "infohash_v2", None),
|
||||
)
|
||||
|
||||
return any(
|
||||
isinstance(identifier, str) and identifier and _hashes_match(identifier, download_id)
|
||||
for identifier in identifiers
|
||||
)
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
raise RuntimeError(message)
|
||||
|
||||
@@ -166,63 +186,6 @@ def _build_qbittorrent_child_path(base_path: object, child_path: object) -> str
|
||||
class QBittorrentClient(DownloadClient):
|
||||
"""qBittorrent download client."""
|
||||
|
||||
def _is_torrent_loaded(self, torrent_hash: str) -> tuple[bool, str | None]:
|
||||
"""Check whether qBittorrent has registered a torrent yet.
|
||||
|
||||
Uses `/api/v2/torrents/properties?hash=<hash>`.
|
||||
|
||||
Returns:
|
||||
(loaded, error_message)
|
||||
|
||||
Notes:
|
||||
A false result with no error means "not loaded yet".
|
||||
|
||||
"""
|
||||
url = f"{self._base_url}/api/v2/torrents/properties"
|
||||
params = {"hash": torrent_hash}
|
||||
|
||||
try:
|
||||
self._ensure_authenticated()
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
# Re-authenticate and retry once on 403
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
|
||||
logger.debug(
|
||||
"qBittorrent returned 403 for properties; re-authenticating and retrying"
|
||||
)
|
||||
self._ensure_authenticated()
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
return False, "qBittorrent authentication failed (HTTP 403)"
|
||||
|
||||
# qBittorrent returns 404/409-ish responses depending on version when missing.
|
||||
if response.status_code == _HTTP_STATUS_NOT_FOUND:
|
||||
return False, None
|
||||
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||
if status == _HTTP_STATUS_NOT_FOUND:
|
||||
return False, None
|
||||
if status:
|
||||
return False, f"qBittorrent API request failed (HTTP {status})"
|
||||
return False, "qBittorrent API request failed"
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False, f"Cannot connect to qBittorrent at {self._base_url}"
|
||||
except requests.exceptions.Timeout:
|
||||
return False, f"qBittorrent request timed out at {self._base_url}"
|
||||
except requests.exceptions.InvalidSchema:
|
||||
return (
|
||||
False,
|
||||
"qBittorrent URL is invalid (missing http:// or https://). "
|
||||
f"Configured: {self._base_url}",
|
||||
)
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
return False, f"qBittorrent API error: {type(e).__name__}: {e}"
|
||||
else:
|
||||
return True, None
|
||||
|
||||
protocol = "torrent"
|
||||
name = "qbittorrent"
|
||||
|
||||
@@ -274,37 +237,18 @@ class QBittorrentClient(DownloadClient):
|
||||
return
|
||||
self._client.auth_log_in()
|
||||
|
||||
def _get_torrents_info(
|
||||
self, torrent_hash: str | None = None, category: str | None = None
|
||||
def _request_torrent_info_records(
|
||||
self, params: dict[str, str]
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
"""Get torrent info using GET.
|
||||
|
||||
Behaviors:
|
||||
- Retry once on HTTP 403 by re-authenticating.
|
||||
- Keep "API/auth/connect" errors distinct from "torrent missing".
|
||||
- If a hash-specific query returns empty, fall back to listing by category
|
||||
and matching locally.
|
||||
- Without a hash, `category` narrows the listing to that category.
|
||||
|
||||
Returns:
|
||||
(torrents, error_message)
|
||||
|
||||
"""
|
||||
"""Request torrent info records from qBittorrent."""
|
||||
url = f"{self._base_url}/api/v2/torrents/info"
|
||||
|
||||
def do_request(params: dict[str, str]) -> requests.Response:
|
||||
try:
|
||||
self._ensure_authenticated()
|
||||
return self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
def parse_response(
|
||||
response: requests.Response,
|
||||
*,
|
||||
request_params: dict[str, str],
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
|
||||
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
|
||||
self._ensure_authenticated()
|
||||
response = self._client._session.get(url, params=request_params, timeout=10)
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
logger.warning("qBittorrent authentication failed (HTTP 403)")
|
||||
@@ -313,43 +257,6 @@ class QBittorrentClient(DownloadClient):
|
||||
response.raise_for_status()
|
||||
torrents = response.json()
|
||||
return [SimpleNamespace(**t) for t in torrents], None
|
||||
|
||||
try:
|
||||
primary_params: dict[str, str] = {}
|
||||
if torrent_hash:
|
||||
primary_params["hashes"] = torrent_hash
|
||||
elif category:
|
||||
primary_params["category"] = category
|
||||
|
||||
response = do_request(primary_params)
|
||||
torrents, error = parse_response(response, request_params=primary_params)
|
||||
if error:
|
||||
return [], error
|
||||
|
||||
if torrent_hash and not torrents:
|
||||
# Fallback 1: list by configured category
|
||||
category_params: dict[str, str] = {}
|
||||
if self._category:
|
||||
category_params["category"] = self._category
|
||||
|
||||
category_response = do_request(category_params)
|
||||
category_torrents, category_error = parse_response(
|
||||
category_response, request_params=category_params
|
||||
)
|
||||
if category_error:
|
||||
return [], category_error
|
||||
|
||||
if category_torrents:
|
||||
return category_torrents, None
|
||||
|
||||
# Fallback 2: list everything (handles per-task categories like audiobooks)
|
||||
all_response = do_request({})
|
||||
all_torrents, all_error = parse_response(all_response, request_params={})
|
||||
if all_error:
|
||||
return [], all_error
|
||||
|
||||
return all_torrents, None
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||
if status:
|
||||
@@ -374,12 +281,66 @@ class QBittorrentClient(DownloadClient):
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
logger.debug("Failed to get torrents info: %s", e)
|
||||
return [], f"qBittorrent API error: {type(e).__name__}: {e}"
|
||||
else:
|
||||
return torrents, None
|
||||
|
||||
def _get_torrent_info(self, download_id: str) -> tuple[SimpleNamespace | None, str | None]:
|
||||
"""Get one torrent by its current qBittorrent hash."""
|
||||
torrents, error = self._request_torrent_info_records({"hashes": download_id})
|
||||
if error or not torrents:
|
||||
return None, error
|
||||
return (
|
||||
next(
|
||||
(
|
||||
torrent
|
||||
for torrent in torrents
|
||||
if isinstance(getattr(torrent, "hash", None), str)
|
||||
and _hashes_match(torrent.hash, download_id)
|
||||
),
|
||||
None,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _list_torrents_by_category(
|
||||
self, category: str | None
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
"""List torrent records in a category, or all records when unset."""
|
||||
params = {"category": category} if category else {}
|
||||
return self._request_torrent_info_records(params)
|
||||
|
||||
def _resolve_torrent(
|
||||
self, download_id: str, category: str | None = None
|
||||
) -> tuple[SimpleNamespace | None, str | None]:
|
||||
"""Resolve any known torrent identity to its current qBittorrent record."""
|
||||
torrent, error = self._get_torrent_info(download_id)
|
||||
if error or torrent:
|
||||
return torrent, error
|
||||
|
||||
categories = [candidate for candidate in (category, self._category) if candidate]
|
||||
for candidate in dict.fromkeys(categories):
|
||||
torrents, error = self._list_torrents_by_category(candidate)
|
||||
if error:
|
||||
return None, error
|
||||
torrent = next(
|
||||
(item for item in torrents if _torrent_matches_download_id(item, download_id)),
|
||||
None,
|
||||
)
|
||||
if torrent:
|
||||
return torrent, None
|
||||
|
||||
torrents, error = self._list_torrents_by_category(None)
|
||||
if error:
|
||||
return None, error
|
||||
return (
|
||||
next(
|
||||
(item for item in torrents if _torrent_matches_download_id(item, download_id)),
|
||||
None,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _list_category_hashes(self, category: str | None) -> set[str] | None:
|
||||
"""Snapshot the hashes qBittorrent currently reports for a category."""
|
||||
torrents, error = self._get_torrents_info(category=category)
|
||||
torrents, error = self._list_torrents_by_category(category)
|
||||
if error:
|
||||
logger.debug("Could not snapshot qBittorrent torrents: %s", error)
|
||||
return None
|
||||
@@ -397,7 +358,7 @@ class QBittorrentClient(DownloadClient):
|
||||
torrent matching the requested rename can identify the new arrival.
|
||||
"""
|
||||
for _ in range(20):
|
||||
torrents, error = self._get_torrents_info(category=category)
|
||||
torrents, error = self._list_torrents_by_category(category)
|
||||
if error:
|
||||
logger.debug("qBittorrent hash discovery: %s", error)
|
||||
else:
|
||||
@@ -534,21 +495,22 @@ class QBittorrentClient(DownloadClient):
|
||||
message = f"{message} (torrent file fetch failed: {torrent_info.fetch_error})"
|
||||
_raise_runtime_error(message)
|
||||
|
||||
# Some qBittorrent-compatible clients return HTTP 200 with an empty body
|
||||
# instead of qBittorrent's literal "Ok." response. Prefer verifying that
|
||||
# the torrent becomes visible over trusting the response body alone.
|
||||
for _ in range(10):
|
||||
loaded, error = self._is_torrent_loaded(expected_hash)
|
||||
# Wait until qBittorrent has resolved magnet metadata so the returned
|
||||
# hash is its stable primary torrent ID, which may differ from the v1 hash.
|
||||
for _ in range(20):
|
||||
torrent, error = self._resolve_torrent(expected_hash, category)
|
||||
if error:
|
||||
logger.debug("qBittorrent add_download: %s", error)
|
||||
if loaded:
|
||||
logger.info("Added torrent: %s", expected_hash)
|
||||
return expected_hash.lower()
|
||||
elif torrent and getattr(torrent, "state", None) not in _METADATA_DOWNLOAD_STATES:
|
||||
torrent_hash = getattr(torrent, "hash", None)
|
||||
if isinstance(torrent_hash, str) and torrent_hash:
|
||||
logger.info("Added torrent: %s", torrent_hash)
|
||||
return torrent_hash.lower()
|
||||
time.sleep(0.5)
|
||||
|
||||
logger.warning(
|
||||
"Torrent add was not confirmed within the visibility grace period (response=%s), returning expected hash",
|
||||
result_text,
|
||||
_raise_runtime_error(
|
||||
"Torrent metadata resolution was not confirmed within the visibility grace period "
|
||||
f"(response={result_text})"
|
||||
)
|
||||
except _QBITTORRENT_CLIENT_ERRORS:
|
||||
logger.exception("qBittorrent add failed")
|
||||
@@ -567,19 +529,9 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
"""
|
||||
try:
|
||||
torrents, error = self._get_torrents_info(download_id)
|
||||
torrent, error = self._get_torrent_info(download_id)
|
||||
if error:
|
||||
return DownloadStatus.error(error)
|
||||
|
||||
torrent = next(
|
||||
(
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(t.hash, download_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not torrent:
|
||||
return DownloadStatus.error("Torrent not found in qBittorrent")
|
||||
|
||||
@@ -705,20 +657,10 @@ class QBittorrentClient(DownloadClient):
|
||||
- join `save_path` with the torrent's top-level directory
|
||||
"""
|
||||
try:
|
||||
torrents, error = self._get_torrents_info(download_id)
|
||||
torrent, error = self._get_torrent_info(download_id)
|
||||
if error:
|
||||
logger.debug("qBittorrent get_download_path: %s", error)
|
||||
return None
|
||||
|
||||
torrent = next(
|
||||
(
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(t.hash, download_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not torrent:
|
||||
return None
|
||||
|
||||
@@ -825,23 +767,19 @@ class QBittorrentClient(DownloadClient):
|
||||
if not torrent_info.info_hash:
|
||||
return None
|
||||
|
||||
torrents, error = self._get_torrents_info(torrent_info.info_hash)
|
||||
if error:
|
||||
logger.debug("qBittorrent find_existing: %s", error)
|
||||
return None
|
||||
|
||||
torrent = next(
|
||||
(
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(t.hash, torrent_info.info_hash)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if torrent and isinstance(getattr(torrent, "hash", None), str):
|
||||
torrent_hash = torrent.hash
|
||||
return (torrent_hash.lower(), self.get_status(torrent_hash.lower()))
|
||||
for _ in range(20):
|
||||
torrent, error = self._resolve_torrent(torrent_info.info_hash, category)
|
||||
if error:
|
||||
logger.debug("qBittorrent find_existing: %s", error)
|
||||
return None
|
||||
if not torrent:
|
||||
return None
|
||||
if getattr(torrent, "state", None) not in _METADATA_DOWNLOAD_STATES:
|
||||
torrent_hash = getattr(torrent, "hash", None)
|
||||
if isinstance(torrent_hash, str) and torrent_hash:
|
||||
torrent_hash = torrent_hash.lower()
|
||||
return (torrent_hash, self.get_status(torrent_hash))
|
||||
time.sleep(0.5)
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
logger.debug("Error checking for existing torrent: %s", e)
|
||||
return None
|
||||
|
||||
@@ -26,6 +26,8 @@ class MockTorrent:
|
||||
dlspeed=1024000,
|
||||
eta=3600,
|
||||
content_path="/downloads/test.txt",
|
||||
infohash_v1=None,
|
||||
infohash_v2=None,
|
||||
):
|
||||
self.hash = hash_val
|
||||
self.name = name
|
||||
@@ -34,10 +36,12 @@ class MockTorrent:
|
||||
self.dlspeed = dlspeed
|
||||
self.eta = eta
|
||||
self.content_path = content_path
|
||||
self.infohash_v1 = infohash_v1
|
||||
self.infohash_v2 = infohash_v2
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dict for JSON response mocking."""
|
||||
return {
|
||||
result = {
|
||||
"hash": self.hash,
|
||||
"name": self.name,
|
||||
"progress": self.progress,
|
||||
@@ -46,6 +50,11 @@ class MockTorrent:
|
||||
"eta": self.eta,
|
||||
"content_path": self.content_path,
|
||||
}
|
||||
if self.infohash_v1 is not None:
|
||||
result["infohash_v1"] = self.infohash_v1
|
||||
if self.infohash_v2 is not None:
|
||||
result["infohash_v2"] = self.infohash_v2
|
||||
return result
|
||||
|
||||
|
||||
def create_mock_session_response(torrents, status_code=200):
|
||||
@@ -657,12 +666,9 @@ class TestQBittorrentClientGetStatus:
|
||||
)
|
||||
|
||||
mock_client_instance = MagicMock()
|
||||
# hashes query empty -> category list empty -> full list empty
|
||||
mock_client_instance._session.get.side_effect = [
|
||||
create_mock_session_response([], status_code=200),
|
||||
create_mock_session_response([], status_code=200),
|
||||
create_mock_session_response([], status_code=200),
|
||||
]
|
||||
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)}):
|
||||
@@ -679,6 +685,39 @@ class TestQBittorrentClientGetStatus:
|
||||
assert status.message is not None
|
||||
assert "not found" in status.message.lower()
|
||||
|
||||
def test_get_status_rejects_unrelated_exact_result(self, monkeypatch):
|
||||
"""Reject an unrelated record when the client ignores the hash filter."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
[MockTorrent(hash_val="different")]
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
status = client.get_status("requested")
|
||||
|
||||
assert status.state_value == "error"
|
||||
assert status.message is not None
|
||||
assert "not found" in status.message.lower()
|
||||
|
||||
def test_get_status_stalled(self, monkeypatch):
|
||||
"""Test status for stalled torrent."""
|
||||
config_values = {
|
||||
@@ -801,9 +840,8 @@ class TestQBittorrentClientAddDownload:
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance.torrents_info.return_value = [mock_torrent]
|
||||
# Used by the properties check
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
{}, status_code=200
|
||||
[mock_torrent], status_code=200
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
@@ -821,6 +859,107 @@ class TestQBittorrentClientAddDownload:
|
||||
assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
||||
assert mock_client_instance._session.get.call_count >= 1
|
||||
|
||||
@pytest.mark.parametrize("metadata_state", ["metaDL", "forcedMetaDL"])
|
||||
def test_add_waits_for_metadata_and_returns_current_hash(self, monkeypatch, metadata_state):
|
||||
"""Return qBittorrent's current hash after hybrid metadata resolves."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.time.sleep", lambda _seconds: None
|
||||
)
|
||||
|
||||
v1_hash = "edf46c7f938a3c678081734d7bff8b9c652ba5e5"
|
||||
v2_hash = "0bed5f40753b342cb143e83c2b21924cc8474731"
|
||||
full_v2_hash = "0bed5f40753b342cb143e83c2b21924cc847473134e44d1bd300bdc58c13010f"
|
||||
metadata_torrent = MockTorrent(
|
||||
hash_val=v1_hash,
|
||||
state=metadata_state,
|
||||
infohash_v1=v1_hash,
|
||||
)
|
||||
resolved_torrent = MockTorrent(
|
||||
hash_val=v2_hash,
|
||||
state="downloading",
|
||||
infohash_v1=v1_hash,
|
||||
infohash_v2=full_v2_hash,
|
||||
)
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance._session.get.side_effect = [
|
||||
create_mock_session_response([metadata_torrent]),
|
||||
create_mock_session_response([]),
|
||||
create_mock_session_response([resolved_torrent]),
|
||||
]
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
magnet = f"magnet:?xt=urn:btih:{v1_hash}&dn=test"
|
||||
result = client.add_download(magnet, "Test Download", category="audiobooks")
|
||||
|
||||
assert result == v2_hash
|
||||
assert [
|
||||
call.kwargs["params"] for call in mock_client_instance._session.get.call_args_list
|
||||
] == [
|
||||
{"hashes": v1_hash},
|
||||
{"hashes": v1_hash},
|
||||
{"category": "audiobooks"},
|
||||
]
|
||||
|
||||
def test_add_fails_when_metadata_never_resolves(self, monkeypatch):
|
||||
"""Fail rather than return a transitional hash after the metadata timeout."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.time.sleep", lambda _seconds: None
|
||||
)
|
||||
|
||||
v1_hash = "edf46c7f938a3c678081734d7bff8b9c652ba5e5"
|
||||
metadata_torrent = MockTorrent(
|
||||
hash_val=v1_hash,
|
||||
state="metaDL",
|
||||
infohash_v1=v1_hash,
|
||||
)
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
[metadata_torrent]
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
magnet = f"magnet:?xt=urn:btih:{v1_hash}&dn=test"
|
||||
with pytest.raises(RuntimeError, match="metadata resolution was not confirmed"):
|
||||
client.add_download(magnet, "Test Download")
|
||||
|
||||
def test_add_download_uses_expected_hash_without_fetch(self, monkeypatch):
|
||||
"""Skip proxy fetch when expected hash is provided for URL torrents."""
|
||||
config_values = {
|
||||
@@ -840,7 +979,7 @@ class TestQBittorrentClientAddDownload:
|
||||
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_torrent], status_code=200
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
@@ -904,11 +1043,10 @@ class TestQBittorrentClientAddDownload:
|
||||
|
||||
torrents_before_add = create_mock_session_response([existing])
|
||||
torrents_after_add = create_mock_session_response([existing, added])
|
||||
properties_ok = create_mock_session_response({}, status_code=200)
|
||||
|
||||
def session_get(request_url, params=None, timeout=None):
|
||||
if request_url.endswith("/torrents/properties"):
|
||||
return properties_ok
|
||||
if params == {"hashes": discovered_hash}:
|
||||
return create_mock_session_response([added])
|
||||
if mock_client_instance.torrents_add.called:
|
||||
return torrents_after_add
|
||||
return torrents_before_add
|
||||
@@ -1062,9 +1200,8 @@ class TestQBittorrentClientAddDownload:
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance.torrents_info.return_value = [mock_torrent]
|
||||
# Used by the properties check
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
{}, status_code=200
|
||||
[mock_torrent], status_code=200
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
@@ -1095,10 +1232,11 @@ class TestQBittorrentClientAddDownload:
|
||||
)
|
||||
|
||||
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
mock_torrent = MockTorrent(hash_val=valid_hash)
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = ""
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
{}, status_code=200
|
||||
[mock_torrent], status_code=200
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
@@ -1161,10 +1299,11 @@ class TestQBittorrentClientAddDownload:
|
||||
)
|
||||
|
||||
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
mock_torrent = MockTorrent(hash_val=valid_hash)
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
{}, status_code=200
|
||||
[mock_torrent], status_code=200
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
@@ -1198,10 +1337,11 @@ class TestQBittorrentClientAddDownload:
|
||||
)
|
||||
|
||||
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
mock_torrent = MockTorrent(hash_val=valid_hash)
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
{}, status_code=200
|
||||
[mock_torrent], status_code=200
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
@@ -1536,6 +1676,74 @@ class TestQBittorrentClientFindExisting:
|
||||
assert download_id == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
||||
assert isinstance(status, DownloadStatus)
|
||||
|
||||
def test_find_existing_waits_for_metadata(self, monkeypatch):
|
||||
"""Wait for an existing hybrid torrent to expose its current primary hash."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.time.sleep", lambda _seconds: None
|
||||
)
|
||||
|
||||
v1_hash = "edf46c7f938a3c678081734d7bff8b9c652ba5e5"
|
||||
v2_hash = "0bed5f40753b342cb143e83c2b21924cc8474731"
|
||||
full_v2_hash = "0bed5f40753b342cb143e83c2b21924cc847473134e44d1bd300bdc58c13010f"
|
||||
metadata_torrent = MockTorrent(
|
||||
hash_val=v1_hash,
|
||||
state="metaDL",
|
||||
infohash_v1=v1_hash,
|
||||
)
|
||||
unrelated_torrent = MockTorrent(hash_val="a" * 40)
|
||||
resolved_torrent = MockTorrent(
|
||||
hash_val=v2_hash,
|
||||
state="downloading",
|
||||
infohash_v1=v1_hash,
|
||||
infohash_v2=full_v2_hash,
|
||||
)
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance._session.get.side_effect = [
|
||||
create_mock_session_response([metadata_torrent]),
|
||||
create_mock_session_response([]),
|
||||
create_mock_session_response([unrelated_torrent]),
|
||||
create_mock_session_response([unrelated_torrent]),
|
||||
create_mock_session_response([resolved_torrent]),
|
||||
create_mock_session_response([resolved_torrent]),
|
||||
]
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
magnet = f"magnet:?xt=urn:btih:{v1_hash}&dn=test"
|
||||
result = client.find_existing(magnet, category="audiobooks")
|
||||
|
||||
assert result is not None
|
||||
download_id, status = result
|
||||
assert download_id == v2_hash
|
||||
assert status.state_value == "downloading"
|
||||
assert [
|
||||
call.kwargs["params"] for call in mock_client_instance._session.get.call_args_list
|
||||
] == [
|
||||
{"hashes": v1_hash},
|
||||
{"hashes": v1_hash},
|
||||
{"category": "audiobooks"},
|
||||
{"category": "books"},
|
||||
{},
|
||||
{"hashes": v2_hash},
|
||||
]
|
||||
|
||||
def test_find_existing_not_found(self, monkeypatch):
|
||||
"""Test finding non-existent torrent."""
|
||||
config_values = {
|
||||
@@ -1641,3 +1849,28 @@ class TestHashesMatch:
|
||||
|
||||
assert _hashes_match("a" * 40, "b" * 30) is False
|
||||
assert _hashes_match("a" * 38, "b" * 32) is False
|
||||
|
||||
|
||||
class TestTorrentMatchesId:
|
||||
"""Tests for matching qBittorrent's available torrent identities."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("torrent", "download_id"),
|
||||
[
|
||||
(MockTorrent(hash_val="current"), "current"),
|
||||
(MockTorrent(hash_val="other", infohash_v1="v1"), "v1"),
|
||||
(MockTorrent(hash_val="other", infohash_v2="v2"), "v2"),
|
||||
],
|
||||
)
|
||||
def test_matches_each_identity(self, torrent, download_id):
|
||||
"""Match IDs exposed through hash, infohash_v1, or infohash_v2."""
|
||||
from shelfmark.download.clients.qbittorrent import _torrent_matches_download_id
|
||||
|
||||
assert _torrent_matches_download_id(torrent, download_id) is True
|
||||
|
||||
def test_rejects_unrelated_or_missing_identities(self):
|
||||
"""Reject unrelated IDs and records without identity fields."""
|
||||
from shelfmark.download.clients.qbittorrent import _torrent_matches_download_id
|
||||
|
||||
assert _torrent_matches_download_id(MockTorrent(hash_val="different"), "requested") is False
|
||||
assert _torrent_matches_download_id(object(), "requested") is False
|
||||
|
||||
Reference in New Issue
Block a user