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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
## 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).
- Explain refused torrent removals, back off unchanged candidates for five minutes, and report actual removal outcomes separately (#639).
- Remove verified empty metadata-stuck magnets after removal policies clear, and prevent them from blocking cleanup of other torrents sharing their save folder (#637, #527).

## v5.14.7-3 (29/10/2026)

Expand Down
19 changes: 17 additions & 2 deletions docs/configuration/seeding.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,23 @@ blocks every removal reason, including custom-format failures and failed categor
Completed torrents must pass their effective ratio/time policy using its configured
AND/OR mode. Incomplete torrents must pass minimum age and stalled grace: `-1` holds
them indefinitely, `0` adds no stalled grace, and positive values require idle minutes.
Blocked requests are reclassified on the next scan. Changed activity, category, location,
or a re-added torrent invalidates the previous decision. Saving settings never deletes torrents.
Blocked candidates remain subject to current classification on each scan. Unchanged refused
candidates back off for five minutes before repeating the final removal and content checks,
including direct content-cleanup calls and unavailable targeted lookups. Elapsed seeding
time alone does not cancel the delay. Refusal records are pruned after a scan no longer
observes the candidate.
Changed activity, progress, ratio, tags, category, location, or a re-added torrent ends that
backoff immediately; tracker or policy changes are checked on the next retry. All protections
are checked again before deletion. Saving settings never deletes torrents.

The INFO log names each new refusal reason per torrent and qBittorrent instance, including
never-remove policy, age or stalled grace, HnR, changed identity, and unverified shared-content
ownership. Unchanged refusals do not repeat their reason or mark the torrent again during
backoff. `Deletion summary` reports `candidates`, `blocked` (including deferred refusals),
`pending` (uncertain client responses), and `confirmed_removed` (successful client responses)
separately. Hash comparisons ignore case; legacy pending work is counted once per hash.
When a later lookup confirms an uncertain removal, an INFO message identifies the hash
and client instance. Arr queue removal and blocklisting still wait for client removal confirmation.

## Overview

Expand Down
6 changes: 6 additions & 0 deletions docs/features/health-monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ 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.

`RemoveTorrent = -1` prevents removal even after stalled grace expires. To enable cleanup,
choose removal mode 1–4 and keep the required HnR protections configured. Refused candidates
log their reason once and retry after five minutes, or sooner when torrent activity changes.
The deletion summary separates candidates, blocked requests, uncertain pending responses,
and successful client removals.

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
Expand Down
116 changes: 103 additions & 13 deletions qBitrr/arss/qbit_side_effects.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import contextlib
import time
from collections import defaultdict
from collections.abc import Callable, Iterable
from pathlib import Path
Expand Down Expand Up @@ -235,6 +236,34 @@ def removal_candidate(torrent: Any) -> tuple:
)


def removal_observation(torrent: Any) -> tuple:
"""Capture identity and activity changes that should end removal backoff."""
return removal_candidate(torrent) + tuple(
getattr(torrent, key, None)
for key in (
"state_enum",
"progress",
"downloaded",
"ratio",
"last_activity",
"tags",
"super_seeding",
)
)


def removal_retry_deferred(worker: Any, instance: str, torrent: Any) -> bool:
"""Defer unchanged refused candidates for five minutes, without authorizing removal."""
key = (instance, torrent.hash.lower())
observation = removal_observation(torrent)
previous = getattr(worker, "_blocked_removals", {}).get(key)
return bool(
previous
and (previous[0] is None or previous[0] == observation)
and time.monotonic() < previous[2]
)


def content_removal_allowed(worker: Any, paths: list[Path]) -> bool:
"""Verify HnR for every current torrent referencing any targeted content path."""
owners = []
Expand Down Expand Up @@ -325,6 +354,10 @@ def reconcile_removal_attempts(worker: Any) -> None:
if not absent:
continue
_confirm_removal_attempt(worker, key)
getattr(worker, "_blocked_removals", {}).pop((instance, hash_.lower()), None)
worker.logger.info(
"Confirmed removal of %s on %s after uncertain response", hash_, instance
)
except Exception as exc:
worker.logger.warning(
"Removal confirmation will retry %s on %s: %s", hash_, instance, type(exc).__name__
Expand All @@ -344,49 +377,98 @@ def delete_hashes_per_instance(
for instance_name, hashes in batches.items():
for hash_ in hashes:
expected = getattr(worker, "_removal_candidates", {}).get((instance_name, hash_))
key = (instance_name, hash_.lower())
if not hasattr(worker, "_removal_observations"):
worker._removal_observations = {}
observation = worker._removal_observations.setdefault(key, None)
previous = getattr(worker, "_blocked_removals", {}).get(key)
if (
previous
and (previous[0] is None or previous[0] == observation)
and time.monotonic() < previous[2]
and (instance_name, hash_) not in getattr(worker, "_pending_client_removals", {})
):
continue

observed = None

def refuse(reason: str, torrent: Any = None, *, warning: bool = False) -> bool:
"""Log a changed refusal once and defer this candidate's next attempt."""
if not hasattr(worker, "_blocked_removals"):
worker._blocked_removals = {}
key = (instance_name, hash_.lower())
observation = (
removal_observation(torrent)
if torrent is not None
else worker._removal_observations.get(key)
)
previous = worker._blocked_removals.get(key)
if previous is None or previous[:2] != (observation, reason):
log = worker.logger.warning if warning else worker.logger.info
log(
"Removal blocked for %s on %s: %s; retry in 300s or after activity changes",
hash_,
instance_name,
reason,
)
worker._blocked_removals[key] = (observation, reason, time.monotonic() + 300)
Comment thread
Feramance marked this conversation as resolved.
Resolved
return False

def attempt() -> bool:
"""Recheck identity and removal protections before each client deletion attempt."""
nonlocal expected
nonlocal expected, observed
current = resolve_removal_torrent(worker, hash_, instance_name)
if current is None:
if (instance_name, hash_) not in getattr(
worker, "_pending_client_removals", {}
):
return False
return refuse("torrent lookup unavailable or torrent absent")
client = worker._get_qbit_client(instance_name)
if client is None:
return False
return refuse("owning client unavailable")
Comment thread
Feramance marked this conversation as resolved.
Resolved
present = any(
t.hash.lower() == hash_.lower()
for t in client.torrents_info(torrent_hashes=hash_)
)
if present:
worker._pending_client_removals.pop((instance_name, hash_), None)
return not present
return (
refuse("torrent still present after uncertain removal")
if present
else True
)
instance, client, torrent = current
observed = torrent
key = (instance_name, hash_)
# Fresh presence disproves the old uncertain removal, even if this retry refuses.
getattr(worker, "_pending_client_removals", {}).pop(key, None)
if removal_retry_deferred(worker, instance, torrent):
return False
snapshot = removal_candidate(torrent)
if expected is None:
expected = snapshot
if expected != snapshot:
return False
return refuse("torrent identity or location changed", torrent)
reason = getattr(worker, "_removal_reasons", {}).get((instance, hash_))
if reason == "CF unmet deletion" and not (
worker.custom_format_unmet_search and worker.custom_format_unmet_check(torrent)
):
return False
return refuse("custom-format failure no longer applies", torrent)
if not worker._deletion_rules_allow(
torrent, "final torrent removal", instance_name=instance, client=client
):
return False
return refuse(
getattr(worker, "_removal_rule_reason", "removal rules refused"), torrent
)
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
if not paths:
return refuse("no verified content paths", torrent)
if not content_removal_allowed(worker, paths):
return refuse(
"shared content ownership or removal protections refused", torrent
)
# A content scan can be lengthy; refresh the candidate again before mutation.
current = resolve_removal_torrent(worker, hash_, instance)
if (
Expand All @@ -397,7 +479,10 @@ def attempt() -> bool:
current[2], "final torrent removal", instance_name=instance, client=client
)
):
return False
return refuse(
"torrent changed or removal protections refused after content scan",
torrent,
)
if not hasattr(worker, "_pending_client_removals"):
worker._pending_client_removals = {}
peers = {instance_name} | {
Expand All @@ -416,7 +501,9 @@ def attempt() -> bool:
)
if result is False or not worker._pending_client_removals[key][1]:
worker._pending_client_removals.pop(key, None)
return result is not False
return (
refuse("qBittorrent rejected deletion", torrent) if result is False else True
)

try:
result = (
Expand All @@ -431,13 +518,16 @@ def attempt() -> bool:
)
)
if result:
getattr(worker, "_blocked_removals", {}).pop(
(instance_name, hash_.lower()), None
)
deleted.add((instance_name, hash_))
worker.logger.info(
"Removed torrent %s from %s after HnR clearance", hash_, instance_name
)
except Exception as exc:
worker.logger.warning(
"Removal retained for %s on %s: %s", hash_, instance_name, type(exc).__name__
refuse(
f"removal lookup or API failure ({type(exc).__name__})", observed, warning=True
)
return deleted

Expand Down
Loading
Loading