diff --git a/CHANGELOG.md b/CHANGELOG.md index cd1be18a..a61477c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +### Bug Fixes +- Remove verified empty metadata-stuck magnets after removal policies clear, and prevent them from blocking cleanup of other torrents sharing their save folder (#637). + ## v5.14.7-3 (29/10/2026) ### 🔧 Maintenance diff --git a/docs/configuration/seeding.md b/docs/configuration/seeding.md index 53a9dcc4..1ea7b720 100644 --- a/docs/configuration/seeding.md +++ b/docs/configuration/seeding.md @@ -1358,7 +1358,9 @@ Verified public torrents using only DHT/PeX can also clear without announce trac qBitrr refreshes torrent and tracker information at the final deletion boundary and on retries. Blocked actions stay pending for a later processing cycle. If client identity or shared-file ownership is ambiguous, content and torrents are preserved. Arr receives queue-only cleanup after qBitrr confirms client removal; it is not independently instructed to remove the torrent. Deliberate queue-only workflows that leave the torrent seeding remain supported. -On older qBittorrent versions without a privacy property, qBitrr uses the DHT/PeX/LSD tracker entries to verify privacy. Missing metadata or incomplete privacy information still blocks removal. An unrelated metadata download in a separate save folder does not block content cleanup. Ordinary tracker limits keep Arr overrides by URI; HnR obligations from both levels still apply. +Metadata downloads (`metaDL` / `forcedMetaDL`) with zero downloaded payload bytes, zero progress, and a successfully retrieved empty file list have no payload to protect. They can clear HnR without privacy or tracker metadata, but minimum age, `StalledDelay`, ignored tags, and removal policy still apply. qBitrr rechecks this condition before removing the client entry with file deletion disabled. Arr queue cleanup and blocklisting follow confirmed client removal as usual. These empty magnets do not block cleanup of other torrents sharing their save folder. + +On older qBittorrent versions without a privacy property, qBitrr uses the DHT/PeX/LSD tracker entries to verify privacy. If privacy cannot be verified, HnR clearance is blocked unless the verified empty-magnet shortcut applies. Unknown downloaded bytes prevent that shortcut; they do not independently block ordinary HnR clearance. A failed file-list lookup during metadata-only verification blocks that clearance attempt. Ordinary HnR clearance does not read the file list, though content deletion still requires verified file ownership. An empty file list alone does not authorize removal. An unrelated metadata download in a separate save folder does not block content cleanup. Ordinary tracker limits keep Arr overrides by URI; HnR obligations from both levels still apply. Completed file-cleanup entries are discarded even if valid media remains for seeding. Only blocked cleanup is retried. Confirmed client removals retain their Arr queue cleanup and replacement-search work across processing cycles if Arr is unavailable; these retries are held in memory until they succeed or qBitrr restarts. diff --git a/docs/features/health-monitoring.md b/docs/features/health-monitoring.md index 5c3b1f69..386c2fda 100644 --- a/docs/features/health-monitoring.md +++ b/docs/features/health-monitoring.md @@ -115,6 +115,8 @@ A torrent is considered "stalled" when: Metadata-stuck torrents use the same `StalledDelay`, `ReSearchStalled`, and `IgnoreTorrentsYoungerThan` settings as stalled downloads. Timing is based on `added_on` for metadata states because qBittorrent can keep updating `last_activity` during metadata retries. +When a metadata-stuck torrent has zero downloaded payload bytes, zero progress, and a verified empty file list, qBitrr removes only its client entry after the removal rules clear. Missing privacy or tracker metadata does not block this empty-magnet cleanup, and the magnet does not block cleanup of other torrents in its save folder. Arr queue removal and blocklisting follow successful client removal. Unknown downloaded bytes disable this shortcut; a failed file-list lookup during metadata-only verification blocks that clearance attempt. Ordinary HnR clearance does not require a downloaded-byte count or file-list lookup, though content deletion still requires verified file ownership. + ### Configuration ```toml diff --git a/qBitrr/arss/qbit_side_effects.py b/qBitrr/arss/qbit_side_effects.py index b6c08cbc..d1eb99b8 100644 --- a/qBitrr/arss/qbit_side_effects.py +++ b/qBitrr/arss/qbit_side_effects.py @@ -8,6 +8,8 @@ from pathlib import Path from typing import Any +from qbittorrentapi import TorrentStates + from qBitrr.arss.arr_shared import ( _QBIT_TORRENT_DELETE_EXCEPTIONS, _QBIT_WRITE_RETRY_EXCEPTIONS, @@ -190,6 +192,20 @@ def resolve_removal_torrent( return matches[0] if len(matches) == 1 else None +def is_metadata_only_torrent(client: Any, torrent: Any) -> bool: + """Verify a metadata download has no payload accounting or declared files.""" + downloaded = getattr(torrent, "downloaded", None) + return ( + getattr(torrent, "state_enum", None) + in (TorrentStates.METADATA_DOWNLOAD, TorrentStates.FORCED_METADATA_DOWNLOAD) + and type(downloaded) is int + and downloaded == 0 + and not isinstance(torrent.progress, bool) + and torrent.progress == 0 + and client.torrents_files(torrent_hash=torrent.hash) == [] + ) + + def _torrent_file_paths(client: Any, torrent: Any) -> list[Path]: """Resolve only file paths contained within the torrent's declared save root.""" root = Path(torrent.save_path) @@ -239,6 +255,8 @@ def content_removal_allowed(worker: Any, paths: list[Path]) -> bool: for target in targets ): continue + if is_metadata_only_torrent(client, torrent): + continue files = _torrent_file_paths(client, torrent) if not files: return False @@ -364,7 +382,8 @@ def attempt() -> bool: torrent, "final torrent removal", instance_name=instance, client=client ): return False - if delete_files: + metadata_only = is_metadata_only_torrent(client, torrent) + if delete_files and not metadata_only: paths = _torrent_file_paths(client, torrent) if not paths or not content_removal_allowed(worker, paths): return False @@ -373,6 +392,7 @@ def attempt() -> bool: if ( current is None or removal_candidate(current[2]) != snapshot + or (metadata_only and not is_metadata_only_torrent(client, current[2])) or not worker._deletion_rules_allow( current[2], "final torrent removal", instance_name=instance, client=client ) @@ -391,7 +411,9 @@ def attempt() -> bool: (queue_followups or {}).get(hash_.lower(), []), peers, ) - result = client.torrents_delete(hashes=[hash_], delete_files=delete_files) + result = client.torrents_delete( + hashes=[hash_], delete_files=delete_files and not metadata_only + ) if result is False or not worker._pending_client_removals[key][1]: worker._pending_client_removals.pop(key, None) return result is not False diff --git a/qBitrr/arss/torrent_limits.py b/qBitrr/arss/torrent_limits.py index 9e19d3be..6a1d7d04 100644 --- a/qBitrr/arss/torrent_limits.py +++ b/qBitrr/arss/torrent_limits.py @@ -528,7 +528,10 @@ def _hnr_allows_delete( client: qbittorrentapi.Client | None = None, ) -> bool: """Require verified clearance for every applicable tracker and category obligation.""" - from qBitrr.arss.qbit_side_effects import resolve_removal_torrent + from qBitrr.arss.qbit_side_effects import ( + is_metadata_only_torrent, + resolve_removal_torrent, + ) from qBitrr.config import CONFIG from qBitrr.qbit_seeding_config import load_qbit_seeding_config @@ -550,6 +553,8 @@ def mode(policy: dict) -> str: if current is None: return False instance_name, client, torrent = current + if is_metadata_only_torrent(client, torrent): + return True properties = client.torrents_properties(torrent_hash=torrent.hash) private = properties.get("is_private") trackers = list(client.torrents_trackers(torrent_hash=torrent.hash)) diff --git a/tests/test_hnr_removal_boundary.py b/tests/test_hnr_removal_boundary.py index 76d797ff..80ecc0b0 100644 --- a/tests/test_hnr_removal_boundary.py +++ b/tests/test_hnr_removal_boundary.py @@ -20,8 +20,11 @@ content_removal_allowed, delete_hashes_on_primary, delete_hashes_per_instance, + is_metadata_only_torrent, ) from qBitrr.arss.torrent_batch import TorrentBatch +from qBitrr.arss.torrent_dispatch import TorrentDispatch +from qBitrr.arss.torrent_inspect import TorrentInspect from qBitrr.arss.torrent_limits import TorrentLimits @@ -30,7 +33,7 @@ def tracker(url="https://tracker.example/announce", status=2, msg=""): return SimpleNamespace(url=url, status=status, msg=msg) -class Worker(TorrentLimits, TorrentBatch): +class Worker(TorrentLimits, TorrentBatch, TorrentDispatch, TorrentInspect): def __init__(self): """Initialize a worker with mocked side effects and explicit removal state.""" self.logger = logging.getLogger("hnr-test") @@ -76,6 +79,7 @@ def add(self, instance="one", private=True, **values): name="Superman 2025 Hybrid", category="movies", progress=0.5416, + downloaded=1024, ratio=0.000249, seeding_time=0, save_path="/downloads", @@ -321,6 +325,135 @@ def test_older_qbit_privacy_uses_localized_discovery_rows(self): self.client.torrents_trackers.return_value.pop() self.assertFalse(self.allowed()) + def test_expired_metadata_dispatch_removes_client_and_confirms_arr_followups(self) -> None: + """Dispatch both empty-magnet states after positive stalled grace expires.""" + self.torrent.progress = 0 + self.torrent.downloaded = 0 + self.torrent.added_on = time.time() - 1200 + self.torrent.last_activity = time.time() + self.worker.logger = MagicMock() + self.worker.stalled_delay = 15 + self.worker.allowed_stalled = True + self.worker.custom_format_unmet_search = False + self.worker.in_tags = MagicMock(return_value=False) + self.worker._should_leave_alone = MagicMock(return_value=(False, -1, False, None, None)) + self.worker.manager.qbit_manager.cache = {} + self.worker.manager.policy_manager_owns_tracker_sync_for_category = MagicMock( + return_value=True + ) + self.seeding["stalled_delay"] = 15 + self.client.torrents_files.return_value = [] + self.client.torrents_properties.return_value = {} + self.client.torrents_trackers.side_effect = RuntimeError("No tracker metadata") + for state in ( + qbittorrentapi.TorrentStates.METADATA_DOWNLOAD, + qbittorrentapi.TorrentStates.FORCED_METADATA_DOWNLOAD, + ): + with self.subTest(state=state): + self.torrent.state_enum = state + self.client.torrents_delete.reset_mock() + self.worker._process_failed_dispatch_queue_deletes.reset_mock() + self.assertFalse(self.worker.delete) + self.assertFalse(self.worker.delete_by_instance) + self.worker._process_single_torrent(self.torrent, "one") + self.assertEqual(self.worker.delete, {"superman"}) + self.assertEqual(self.worker.delete_by_instance, {"one": {"superman"}}) + self.worker._process_failed() + self.client.torrents_delete.assert_called_once_with( + hashes=["superman"], delete_files=False + ) + self.worker._process_failed_dispatch_queue_deletes.assert_called_once_with( + {"superman"}, set(), cross_arr=False + ) + self.assertNotIn("one", self.worker.delete_by_instance) + self.client.torrents_properties.assert_not_called() + self.client.torrents_trackers.assert_not_called() + + def test_metadata_only_removal_preserves_age_stall_and_never_remove_rules(self) -> None: + """Having no payload does not authorize early or disabled torrent removal.""" + self.torrent.state_enum = qbittorrentapi.TorrentStates.METADATA_DOWNLOAD + self.torrent.progress = 0 + self.torrent.downloaded = 0 + self.client.torrents_files.return_value = [] + for values, policy in ( + ({"added_on": time.time()}, {}), + ({"added_on": time.time() - 3600}, {"StalledDelay": 240}), + ({"added_on": 1}, {"StalledDelay": -1}), + ({"added_on": 1}, {"RemoveTorrent": -1}), + ({"added_on": 1, "tags": "qBitrr-ignored"}, {}), + ): + with self.subTest(values=values, policy=policy): + self.torrent.__dict__.update(values) + original = dict(self.seeding["default_seeding"]) + self.seeding["default_seeding"].update(policy) + self.assertFalse(delete_hashes_per_instance(self.worker, {"one": {"superman"}})) + self.client.torrents_delete.assert_not_called() + self.seeding["default_seeding"] = original + + def test_metadata_only_requires_verified_empty_payload(self) -> None: + """Unknown accounting, ordinary states, and nonempty file lists stay protected.""" + self.torrent.state_enum = qbittorrentapi.TorrentStates.METADATA_DOWNLOAD + self.torrent.progress = 0 + self.torrent.downloaded = 0 + self.client.torrents_files.return_value = [] + self.assertTrue(is_metadata_only_torrent(self.client, self.torrent)) + for key, value in ( + ("downloaded", None), + ("downloaded", False), + ("downloaded", -1), + ("downloaded", 1), + ("progress", 0.1), + ("progress", False), + ("state_enum", qbittorrentapi.TorrentStates.DOWNLOADING), + ): + with self.subTest(key=key, value=value): + original = getattr(self.torrent, key) + setattr(self.torrent, key, value) + self.assertFalse(is_metadata_only_torrent(self.client, self.torrent)) + setattr(self.torrent, key, original) + for files in (None, [SimpleNamespace(name="movie.mkv")]): + self.client.torrents_files.return_value = files + self.assertFalse(is_metadata_only_torrent(self.client, self.torrent)) + self.client.torrents_files.side_effect = RuntimeError("Unavailable files") + self.assertFalse(delete_hashes_per_instance(self.worker, {"one": {"superman"}})) + self.client.torrents_delete.assert_not_called() + + def test_metadata_only_candidate_rechecks_new_metadata_before_removal(self) -> None: + """Do not use metadata-only clearance after metadata or payload appears.""" + self.torrent.state_enum = qbittorrentapi.TorrentStates.METADATA_DOWNLOAD + self.torrent.progress = 0 + self.torrent.downloaded = 0 + self.client.torrents_files.return_value = [] + replacement = SimpleNamespace(**vars(self.torrent)) + replacement.state_enum = qbittorrentapi.TorrentStates.DOWNLOADING + self.client.torrents_info.side_effect = [[self.torrent], [replacement]] + self.assertFalse(delete_hashes_per_instance(self.worker, {"one": {"superman"}})) + self.client.torrents_delete.assert_not_called() + self.client.torrents_info.side_effect = None + self.client.torrents_files.side_effect = [[], [], [SimpleNamespace(name="movie.mkv")]] + self.assertFalse(delete_hashes_per_instance(self.worker, {"one": {"superman"}})) + self.client.torrents_delete.assert_not_called() + + def test_metadata_only_peer_does_not_veto_shared_folder_cleanup(self) -> None: + """Ignore empty magnets on another client while retaining real file owners.""" + self.torrent.ratio = 2 + other, client = self.worker.add( + "two", + hash="magnet", + progress=0, + downloaded=0, + state_enum=qbittorrentapi.TorrentStates.METADATA_DOWNLOAD, + ) + client.torrents_files.return_value = [] + targets = [Path("/downloads/movie.mkv")] + self.assertTrue(content_removal_allowed(self.worker, targets)) + other.downloaded = 1 + self.assertFalse(content_removal_allowed(self.worker, targets)) + other.downloaded = 0 + other.progress = 0.5 + client.torrents_files.return_value = [SimpleNamespace(name="movie.mkv")] + self.assertFalse(content_removal_allowed(self.worker, targets)) + def test_unrelated_metadata_and_unsafe_files_do_not_veto_content_cleanup(self): """Verify unrelated metadata and unsafe files do not veto content cleanup.""" self.torrent.ratio = 2