From e93fbd2a9ba5338ecd7136e72b130d2163b148ac Mon Sep 17 00:00:00 2001 From: CamGoed <142066276+CamGoed@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:19:25 -0700 Subject: [PATCH] Fix issue with stale v1 hash queries to qbittorrent (#1162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) ```
The successful torrent:
image
v1 vs v2 hash:
image 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 ``` --- shelfmark/download/clients/qbittorrent.py | 284 +++++++++------------- tests/prowlarr/test_qbittorrent_client.py | 269 ++++++++++++++++++-- 2 files changed, 362 insertions(+), 191 deletions(-) diff --git a/shelfmark/download/clients/qbittorrent.py b/shelfmark/download/clients/qbittorrent.py index d150a71e..f38aee1f 100644 --- a/shelfmark/download/clients/qbittorrent.py +++ b/shelfmark/download/clients/qbittorrent.py @@ -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=`. - - 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 diff --git a/tests/prowlarr/test_qbittorrent_client.py b/tests/prowlarr/test_qbittorrent_client.py index f0465b80..b80635d8 100644 --- a/tests/prowlarr/test_qbittorrent_client.py +++ b/tests/prowlarr/test_qbittorrent_client.py @@ -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