Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/configuration/seeding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions docs/features/health-monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 24 additions & 2 deletions qBitrr/arss/qbit_side_effects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
)
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion qBitrr/arss/torrent_limits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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))
Expand Down
135 changes: 134 additions & 1 deletion tests/test_hnr_removal_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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")
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
Loading