Skip to content

Commit 57a5c49

Browse files
authored
Merge pull request #364 from alexis-morain/feat/metadata-missing-client-agnostic
[NEEDS CODE REVIEWER] feat(remove_metadata_missing): detect stuck metadata for non-qBittorrent clients (opt-in)
2 parents 6564212 + a41f679 commit 57a5c49

5 files changed

Lines changed: 140 additions & 2 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,11 +561,13 @@ This is the interesting section. It defines which job you want decluttarr to run
561561
- Steers whether downloads stuck obtaining metadata are removed from the queue
562562
- Blocklisted: Yes
563563
- Type: Boolean or Dict
564-
- Permissible Values: True, False or max_strikes (int)
564+
- Permissible Values: True, False or max_strikes (int), detect_via_missing_size (bool)
565565
- Is Mandatory: No (Defaults to False)
566566
- Note:
567567
- With max_strikes you can define how many times this torrent can be caught before being removed
568568
- Instead of configuring it here, you may also configure it as a default across all jobs or use the built-in defaults (see further above under "max_strikes")
569+
- By default, this check relies on the "qBittorrent is downloading metadata" message that qBittorrent surfaces in the \*arr queue. Other download clients (e.g. Transmission, Deluge) do not surface such a message, so a torrent stuck fetching metadata stays undetected (see [#57](https://github.com/ManiMatter/decluttarr/issues/57)).
570+
- Set `detect_via_missing_size: true` to additionally flag, regardless of download client, queued items whose size is not yet known (size 0), which is the client-agnostic signature of "no metadata yet". This is debounced by max_strikes. Defaults to False to keep existing (qBittorrent) behavior unchanged.
569571

570572
#### REMOVE_MISSING_FILES
571573

src/jobs/remove_metadata_missing.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,20 @@ class RemoveMetadataMissing(RemovalJob):
66
blocklist = True
77

88
async def _find_affected_items(self):
9+
# qBittorrent surfaces a dedicated *arr error message while fetching metadata.
910
conditions = [("queued", "qBittorrent is downloading metadata")]
10-
return self.queue_manager.filter_queue(self.queue, conditions)
11+
affected_items = self.queue_manager.filter_queue(self.queue, conditions)
12+
13+
# Other clients (e.g. Transmission, Deluge) do not surface such a message, so a
14+
# torrent stuck fetching metadata is invisible to the message-based check above
15+
# (see #57). Opt-in fallback: also flag queued items whose size is not yet known
16+
# (size == 0), which is the client-agnostic signature of "no metadata yet".
17+
# Debounced by max_strikes like the rest of this job.
18+
if getattr(self.job, "detect_via_missing_size", False):
19+
seen = {id(item) for item in affected_items}
20+
for item in self.queue_manager.filter_missing_size(self.queue):
21+
if id(item) not in seen:
22+
affected_items.append(item)
23+
seen.add(id(item))
24+
25+
return affected_items

src/settings/_jobs.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class JobParams:
1414
max_concurrent_searches: int
1515
min_days_between_searches: int
1616
target_tags: list
17+
detect_via_missing_size: bool = False
1718

1819
def __init__(
1920
self,
@@ -25,6 +26,7 @@ def __init__(
2526
max_concurrent_searches=None,
2627
min_days_between_searches=None,
2728
target_tags=None,
29+
detect_via_missing_size=None,
2830
):
2931
self.enabled = enabled
3032
self.keep_archives = keep_archives
@@ -34,6 +36,7 @@ def __init__(
3436
self.max_concurrent_searches = max_concurrent_searches
3537
self.min_days_between_searches = min_days_between_searches
3638
self.target_tags = target_tags
39+
self.detect_via_missing_size = detect_via_missing_size
3740

3841
# Remove attributes that are None to keep the object clean
3942
self._remove_none_attributes()
@@ -90,6 +93,7 @@ def _set_job_defaults(self):
9093
)
9194
self.remove_metadata_missing = JobParams(
9295
max_strikes=self.job_defaults.max_strikes,
96+
detect_via_missing_size=False,
9397
)
9498
self.remove_missing_files = JobParams()
9599
self.remove_orphans = JobParams()

src/utils/queue_manager.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,21 @@ def filter_queue(
243243
filtered_items.append(item)
244244
break
245245
return filtered_items
246+
247+
@staticmethod
248+
def filter_missing_size(queue: list[dict]) -> list[dict]:
249+
"""
250+
Return queued items whose size is not yet known (no metadata fetched).
251+
252+
Unlike qBittorrent, clients such as Transmission or Deluge do not surface a
253+
"downloading metadata" message in the *arr queue, so a torrent stuck fetching
254+
metadata cannot be matched by message. Such items are reported by the *arr as
255+
status "queued" with size 0. This client-agnostic check catches them (see #57).
256+
257+
The "size" key must be present so that incomplete items are not matched.
258+
"""
259+
return [
260+
item
261+
for item in queue
262+
if item.get("status") == "queued" and "size" in item and not item["size"]
263+
]

tests/jobs/test_remove_metadata_missing.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from unittest.mock import MagicMock
2+
13
import pytest
24

35
from src.jobs.remove_metadata_missing import RemoveMetadataMissing
@@ -91,3 +93,100 @@ async def test_find_affected_items(queue_data, expected_download_ids):
9193

9294
# Act and Assert
9395
await shared_test_affected_items(removal_job, expected_download_ids)
96+
97+
98+
# Tests the opt-in, client-agnostic detection of items stuck without metadata
99+
# (status "queued" + size 0), e.g. on Transmission/Deluge which do not surface the
100+
# qBittorrent "downloading metadata" message in the *arr queue (see issue #57).
101+
@pytest.mark.asyncio
102+
@pytest.mark.parametrize(
103+
("detect_via_missing_size", "queue_data", "expected_download_ids"),
104+
[
105+
# Disabled (default): a queued size-0 item is NOT flagged; only the qBit message is.
106+
(
107+
False,
108+
[
109+
{
110+
"id": 1,
111+
"downloadId": "a",
112+
"status": "queued",
113+
"size": 0,
114+
"errorMessage": None,
115+
},
116+
{
117+
"id": 2,
118+
"downloadId": "b",
119+
"status": "queued",
120+
"errorMessage": "qBittorrent is downloading metadata",
121+
},
122+
],
123+
["b"],
124+
),
125+
# Enabled: queued + size 0 is flagged; size > 0 and non-queued are left alone.
126+
(
127+
True,
128+
[
129+
{
130+
"id": 1,
131+
"downloadId": "a",
132+
"status": "queued",
133+
"size": 0,
134+
"errorMessage": None,
135+
},
136+
{
137+
"id": 2,
138+
"downloadId": "b",
139+
"status": "queued",
140+
"size": 1234,
141+
"errorMessage": None,
142+
},
143+
{
144+
"id": 3,
145+
"downloadId": "c",
146+
"status": "downloading",
147+
"size": 0,
148+
"errorMessage": None,
149+
},
150+
],
151+
["a"],
152+
),
153+
# Enabled: an item matching BOTH the qBit message and size 0 is not duplicated.
154+
(
155+
True,
156+
[
157+
{
158+
"id": 1,
159+
"downloadId": "a",
160+
"status": "queued",
161+
"size": 0,
162+
"errorMessage": "qBittorrent is downloading metadata",
163+
},
164+
{
165+
"id": 2,
166+
"downloadId": "b",
167+
"status": "queued",
168+
"size": 0,
169+
"errorMessage": None,
170+
},
171+
],
172+
["a", "b"],
173+
),
174+
# Enabled but no size key present (e.g. partial item): not matched.
175+
(
176+
True,
177+
[
178+
{"id": 1, "downloadId": "a", "status": "queued", "errorMessage": None},
179+
],
180+
[],
181+
),
182+
],
183+
)
184+
async def test_find_affected_items_via_missing_size(
185+
detect_via_missing_size, queue_data, expected_download_ids
186+
):
187+
# Arrange
188+
removal_job = shared_fix_affected_items(RemoveMetadataMissing, queue_data)
189+
removal_job.job = MagicMock(detect_via_missing_size=detect_via_missing_size)
190+
191+
# Act and Assert
192+
await shared_test_affected_items(removal_job, expected_download_ids)

0 commit comments

Comments
 (0)