diff --git a/pyproject.toml b/pyproject.toml index 9d67c08..75a6259 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "archive-clients" -version = "0.1.15" +version = "0.1.16" requires-python = ">=3.11" dependencies = ["protobuf==7.35.1", "websockets==16.0"] diff --git a/src/archive_clients/qbittorrent.py b/src/archive_clients/qbittorrent.py index bf975b5..819be85 100644 --- a/src/archive_clients/qbittorrent.py +++ b/src/archive_clients/qbittorrent.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging import threading import time import uuid @@ -16,6 +17,8 @@ from urllib import error, parse, request from archive_clients.config import ServiceConfig from archive_clients.resources import NormalizedResource, normalize_resource +logger = logging.getLogger(__name__) + class QBittorrentError(RuntimeError): pass @@ -72,7 +75,17 @@ class QBittorrentReader: torrent_hash = torrent.get("hash") if not isinstance(torrent_hash, str): raise QBittorrentError("qBittorrent torrent hash is invalid") - result.append(self._normalize(torrent, torrent_hash)) + try: + result.append(self._normalize(torrent, torrent_hash)) + except ValueError as exc: + # A stale or malformed qBittorrent entry must not hide every + # otherwise valid resource from Archive/Evict inventory. + # Keep it ineligible and leave an operator-visible diagnosis. + logger.warning( + "Skipping qBittorrent resource with inconsistent " + "metadata: hash=%s name=%r reason=%s", + torrent_hash, torrent.get("name"), exc, + ) return result def get_resource(self, torrent_hash: str) -> NormalizedResource | None: diff --git a/tests/test_qbittorrent.py b/tests/test_qbittorrent.py index f5848b1..0744479 100644 --- a/tests/test_qbittorrent.py +++ b/tests/test_qbittorrent.py @@ -205,6 +205,54 @@ class QBittorrentReaderTests(unittest.TestCase): self.assertEqual(resources, []) self.assertEqual(len(opener.calls), 2) + def test_listing_skips_malformed_torrent_and_keeps_valid_resources(self): + def torrent(name, content): + info = { + b"length": len(content), b"name": name.encode(), + b"piece length": 16384, b"pieces": b"x" * 20, + } + return encode({b"info": info}), hashlib.sha1(encode(info)).hexdigest() + + first_bytes, first_hash = torrent("first.txt", b"one") + malformed_bytes, malformed_hash = torrent("broken.txt", b"bad") + second_bytes, second_hash = torrent("second.txt", b"two") + torrents = [ + {"hash": first_hash, "name": "first.txt", "state": "uploading"}, + {"hash": malformed_hash, "name": "broken.txt", "state": "stalledUP"}, + {"hash": second_hash, "name": "second.txt", "state": "uploading"}, + ] + responses = [ + b"Ok.", json.dumps(torrents).encode(), + b'[{"index":0,"name":"first.txt","size":3,"progress":1,"priority":1}]', + first_bytes, + b'[{"index":0,"name":"broken.txt","size":3,"progress":1,"priority":1},' + b'{"index":1,"name":"unexpected.txt","size":1,"progress":1,"priority":1}]', + malformed_bytes, + b'[{"index":0,"name":"second.txt","size":3,"progress":1,"priority":1}]', + second_bytes, + ] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + password = root / "password" + password.write_text("secret", encoding="utf-8") + os.chmod(password, 0o600) + config = ServiceConfig( + "http://qb", PurePosixPath("/downloads"), root, + username="admin", password_file=password, + ) + opener = _Opener(responses) + with patch( + "archive_clients.qbittorrent.request.build_opener", + return_value=opener, + ), self.assertLogs("archive_clients.qbittorrent", "WARNING") as logs: + resources = QBittorrentReader(config).list_resources() + + self.assertEqual( + [resource.summary.display_name for resource in resources], + ["first.txt", "second.txt"], + ) + self.assertIn(malformed_hash, "\n".join(logs.output)) + def test_stopped_add_selection_recheck_and_entry_only_delete(self): torrent_hash = "a" * 40 responses = [