Skip to content

Commit 20a668f

Browse files
authored
[Feature] Add new job remove_done_seeding for removing finished torrents
[Feature] Add new job remove_done_seeding for removing finished torrents
2 parents be2b2d0 + b39b8d2 commit 20a668f

File tree

11 files changed

+626
-26
lines changed

11 files changed

+626
-26
lines changed

README.md

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Looking to **upgrade from V1 to V2**? Look [here](#upgrading-from-v1-to-v2)
3838
- [REMOVE_SLOW](#remove_slow)
3939
- [REMOVE_STALLED](#remove_stalled)
4040
- [REMOVE_UNMONITORED](#remove_unmonitored)
41+
- [REMOVE_DONE_SEEDING](#remove_done_seeding)
4142
- [SEARCH_CUTOFF_UNMET](#search_unmet_cutoff)
4243
- [SEARCH_MISSING](#search_missing)
4344
- [DETECT_DELETIONS](#detect_deletions)
@@ -49,8 +50,6 @@ Looking to **upgrade from V1 to V2**? Look [here](#upgrading-from-v1-to-v2)
4950
- [WHISPARR](#whisparr)
5051
- [Downloaders](#download-clients)
5152
- [QBITTORRENT](#qbittorrent)
52-
- [SABNZBD](#sabnzbd)
53-
- [Disclaimer](#disclaimer)
5453

5554
## Overview
5655

@@ -69,6 +68,7 @@ Feature overview:
6968
- Removing downloads that are repeatedly have been found to be slow (remove_slow)
7069
- Removing downloads that are stalled (remove_stalled)
7170
- Removing downloads belonging to movies/series/albums etc. that have been marked as "unmonitored" (remove_unmonitored)
71+
- Removing completed downloads from your download client that match certain criteria (remove_done_seeding)
7272
- Periodically searching for better content on movies/series/albums etc. where cutoff has not been reached yet (search_cutoff_unmet)
7373
- Periodically searching for missing content that has not yet been found (search_missing)
7474

@@ -227,6 +227,11 @@ services:
227227
# As written above, these can also be set as Job Defaults so you don't have to specify them as granular as below.
228228
# REMOVE_BAD_FILES: |
229229
# keep_archives: True
230+
# REMOVE_DONE_SEEDING: |
231+
# target_tags:
232+
# - "Obsolete"
233+
# target_categories:
234+
# - "autobrr"
230235
# REMOVE_FAILED_DOWNLOADS: True
231236
# REMOVE_FAILED_IMPORTS: |
232237
# message_patterns:
@@ -326,9 +331,9 @@ Decluttarr v2 is a major update with a cleaner config format and powerful new fe
326331
- 🧼 **Bad files handling**: Added ability to not download potentially malicious files and files such as trailers / samples
327332
- 🐌 **Adaptive slowness**: Slow downloads-removal can be dynamically turned on/off depending on overall bandwidth usage
328333
- 📄 **Log files**: Logs can now be retrieved from a log file
329-
- 📌 **Removal behavior**: Rather than removing downloads, they can now also be tagged for later removal (ie. to allow for seed targets to be reached first). This can be done separately for private and public trackers
334+
- 🗑️ **Removal behavior**: Rather than removing downloads, they can now also be tagged for later removal (ie. to allow for seed targets to be reached first). This can be done separately for private and public trackers
330335
- 📌 **Deletion detection**: If movies or tv shows get deleted (for instance via Plex), decluttarr can notice that and refresh the respective item
331-
336+
- ⛓️ **Being a good seeder**: A new job allows you to wait with the removal until your seed goals have been achieved
332337
---
333338
334339
### ⚠️ Breaking Changes
@@ -407,7 +412,7 @@ Configures the general behavior of the application (across all features)
407412
- Allows you to configure download client names that will be skipped by decluttarr
408413
Note: The names provided here have to 100% match with how you have named your download clients in your *arr application(s)
409414
- Type: List of strings
410-
- Is Mandatory: No (Defaults to [], i.e. nothing ignored])
415+
- Is Mandatory: No (Defaults to [], i.e. nothing ignored)
411416

412417
#### PRIVATE_TRACKER_HANDLING / PUBLIC_TRACKER_HANDLING
413418

@@ -496,6 +501,30 @@ This is the interesting section. It defines which job you want decluttarr to run
496501
- This may be helpful if you use a tool such as [unpackerr](https://github.com/Unpackerr/unpackerr) that can handle it
497502
- However, you may also find that these packages may contain bad/malicious files (which will not removed by decluttarr)
498503

504+
#### REMOVE_DONE_SEEDING
505+
506+
- Removes downloads that are completed and are done with seeding from the download client's queue when they meet your selection criteria (tags and/or categories).
507+
- "Done Seeding" means that the Ratio limit or Seeding Time limit for your download has been reached
508+
- The limits are taken from your global settings in your download client, or the download-specific overrides
509+
- Type: Boolean or Dict
510+
- Permissible Values:
511+
- If Bool: True, False
512+
- If Dict:
513+
- `target_tags`: List of tag names to match
514+
- `target_categories`: List of category names to match
515+
- Matching logic:
516+
- Requires at least one of `target_tags` or `target_categories`. If neither is provided, the configured obsolete tag will be used as target_tag
517+
- A torrent must be completed AND match (category IN `target_categories`) OR (has any tag IN `target_tags`)
518+
- If both tags and categories are provided, the condition is OR between them
519+
- Is Mandatory: No (Defaults to False)
520+
- Notes:
521+
- This job currently only supports qBittorrent
522+
- Works great together with `obsolete_tag`: have other jobs tag torrents (e.g., "Obsolete") and let this job remove them once completed
523+
- Why not set "Remove torrent and its files" upon reaching seeding goals in download client?
524+
- This setting is discouraged by *arrs and you will get warnings about it
525+
- You get more granular control
526+
- You can use this job to clean up after other apps like autobrr that do not have any torrent management features
527+
499528
#### REMOVE_FAILED_DOWNLOADS
500529

501530
- Steers whether downloads that are marked as "failed" are removed from the queue
@@ -584,6 +613,7 @@ This is the interesting section. It defines which job you want decluttarr to run
584613
- Permissible Values: True, False
585614
- Is Mandatory: No (Defaults to False)
586615

616+
587617
#### SEARCH_UNMET_CUTOFF
588618

589619
- Steers whether searches are automatically triggered for items that are wanted and have not yet met the cutoff

config/config_example.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ job_defaults:
1717
jobs:
1818
remove_bad_files:
1919
# keep_archives: true
20+
remove_done_seeding:
21+
# target_tags:
22+
# - "Obsolete"
23+
# target_categories:
24+
# - "autobrr"
2025
remove_failed_downloads:
2126
remove_failed_imports:
2227
message_patterns:

main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,11 @@ async def main():
6767
await job_manager.run_jobs(arr)
6868
logger.verbose("")
6969

70+
# Run download client jobs (these run independently of *arr instances)
71+
await job_manager.run_download_client_jobs()
72+
7073
# Wait for the next run
7174
await wait_next_run()
72-
return
7375

7476

7577
if __name__ == "__main__":

src/job_manager.py

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Cleans the download queue
22
from src.jobs.remove_bad_files import RemoveBadFiles
3+
from src.jobs.remove_done_seeding import RemoveDoneSeeding
34
from src.jobs.remove_failed_downloads import RemoveFailedDownloads
45
from src.jobs.remove_failed_imports import RemoveFailedImports
56
from src.jobs.remove_metadata_missing import RemoveMetadataMissing
@@ -9,6 +10,7 @@
910
from src.jobs.remove_stalled import RemoveStalled
1011
from src.jobs.remove_unmonitored import RemoveUnmonitored
1112
from src.jobs.search_handler import SearchHandler
13+
from src.settings._download_clients import DOWNLOAD_CLIENT_TYPES
1214
from src.utils.log_setup import logger
1315
from src.utils.queue_manager import QueueManager
1416

@@ -25,6 +27,39 @@ async def run_jobs(self, arr):
2527
await self.removal_jobs()
2628
await self.search_jobs()
2729

30+
async def run_download_client_jobs(self):
31+
"""Run jobs that operate on download clients directly."""
32+
if not await self._download_clients_connected():
33+
return None
34+
35+
items_detected = 0
36+
for download_client_type in DOWNLOAD_CLIENT_TYPES:
37+
download_clients = getattr(
38+
self.settings.download_clients,
39+
download_client_type,
40+
[],
41+
)
42+
43+
for client in download_clients:
44+
# Get jobs for this client
45+
download_client_jobs = self._get_download_client_jobs_for_client(
46+
client,
47+
download_client_type,
48+
)
49+
50+
if not any(job.job.enabled for job in download_client_jobs):
51+
continue
52+
53+
logger.info(
54+
f"*** Running jobs on {client.name} ({client.base_url}) ***",
55+
)
56+
57+
for download_client_job in download_client_jobs:
58+
if download_client_job.job.enabled:
59+
items_detected += await download_client_job.run()
60+
61+
return items_detected
62+
2863
async def removal_jobs(self):
2964
# Check removal jobs
3065
removal_jobs = self._get_removal_jobs()
@@ -72,7 +107,7 @@ async def search_jobs(self):
72107

73108
async def _queue_has_items(self):
74109
logger.debug(
75-
f"job_manager.py/_queue_has_items (Before any removal jobs): Checking if any items in full queue"
110+
"job_manager.py/_queue_has_items (Before any removal jobs): Checking if any items in full queue",
76111
)
77112
queue_manager = QueueManager(self.arr, self.settings)
78113
full_queue = await queue_manager.get_queue_items("full")
@@ -99,11 +134,11 @@ async def _download_clients_connected(self):
99134
async def _check_client_connection_status(self, clients):
100135
for client in clients:
101136
logger.debug(
102-
f"job_manager.py/_check_client_connection_status: Checking if {client.name} is connected"
137+
f"job_manager.py/_check_client_connection_status: Checking if {client.name} is connected",
103138
)
104139
if not await client.check_connected():
105140
logger.warning(
106-
f">>> {client.name} is disconnected. Skipping queue cleaning on {self.arr.name}."
141+
f">>> {client.name} is disconnected. Skipping queue cleaning on {self.arr.name}.",
107142
)
108143
return False
109144
return True
@@ -133,3 +168,26 @@ def _get_removal_jobs(self):
133168
removal_job_class(self.arr, self.settings, removal_job_name),
134169
)
135170
return jobs
171+
172+
def _get_download_client_jobs_for_client(self, client, client_type):
173+
"""
174+
Return a list of download client job instances for a specific download client.
175+
176+
Each job is included if the corresponding attribute exists and is truthy in settings.jobs.
177+
"""
178+
download_client_job_classes = {
179+
"remove_done_seeding": RemoveDoneSeeding,
180+
}
181+
182+
jobs = []
183+
for job_name, job_class in download_client_job_classes.items():
184+
if getattr(self.settings.jobs, job_name, False):
185+
jobs.append(
186+
job_class(
187+
client,
188+
client_type,
189+
self.settings,
190+
job_name,
191+
),
192+
)
193+
return jobs
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
from abc import ABC, abstractmethod
2+
3+
from src.utils.log_setup import logger
4+
5+
6+
class DownloadClientRemovalJob(ABC):
7+
"""Base class for removal jobs that run on download clients directly."""
8+
9+
job_name = None
10+
11+
def __init__(
12+
self,
13+
download_client: object,
14+
download_client_type: str,
15+
settings: object,
16+
job_name: str,
17+
) -> None:
18+
self.download_client = download_client
19+
self.download_client_type = download_client_type
20+
self.settings = settings
21+
self.job_name = job_name
22+
self.job = getattr(self.settings.jobs, self.job_name)
23+
24+
async def run(self) -> int:
25+
"""Run the download client job."""
26+
if not self.job.enabled:
27+
return 0
28+
29+
logger.debug(
30+
f"download_client_job.py/run: Launching job '{self.job_name}' on {self.download_client.name} "
31+
f"({self.download_client_type})",
32+
)
33+
34+
all_items = await self._get_all_items()
35+
if not all_items:
36+
return 0
37+
38+
items_to_remove = await self._get_items_to_remove(all_items)
39+
40+
# Filter out protected items
41+
items_to_remove = self._filter_protected_items(items_to_remove)
42+
43+
if not items_to_remove:
44+
logger.debug(f"No items to remove for job '{self.job_name}'.")
45+
return 0
46+
47+
# Remove the affected items
48+
await self._remove_items(items_to_remove)
49+
50+
return len(items_to_remove)
51+
52+
async def _get_all_items(self) -> list:
53+
"""Get all items from the download client."""
54+
try:
55+
if self.download_client_type == "qbittorrent":
56+
return await self.download_client.get_qbit_items()
57+
if self.download_client_type == "sabnzbd":
58+
return await self.download_client.get_history_items()
59+
except Exception as e:
60+
logger.error(
61+
f"Error fetching items from {self.download_client.name}: {e}",
62+
)
63+
return []
64+
65+
def _filter_protected_items(self, items: list) -> list:
66+
"""Filter out items that are protected by tags or categories."""
67+
protected_tag = getattr(self.settings.general, "protected_tag", None)
68+
if not protected_tag:
69+
return items
70+
71+
filtered_items = []
72+
for item in items:
73+
is_protected = False
74+
item_name = item.get("name", "unknown")
75+
if self.download_client_type == "qbittorrent":
76+
tags = item.get("tags", "").split(",")
77+
tags = [tag.strip() for tag in tags if tag.strip()]
78+
category = item.get("category", "")
79+
if protected_tag in tags or protected_tag == category:
80+
is_protected = True
81+
elif self.download_client_type == "sabnzbd":
82+
category = item.get("category", "")
83+
if protected_tag == category:
84+
is_protected = True
85+
86+
if is_protected:
87+
logger.debug(f"Ignoring protected item: {item_name}")
88+
else:
89+
filtered_items.append(item)
90+
91+
return filtered_items
92+
93+
@abstractmethod
94+
async def _get_items_to_remove(self, items: list) -> list:
95+
"""Return a list of items to remove from the download client."""
96+
97+
async def _remove_items(self, items: list) -> None:
98+
"""Remove the affected items from the download client."""
99+
if self.settings.general.test_run:
100+
logger.info("Test run is enabled. Skipping actual removal.")
101+
for item in items:
102+
item_name = item.get("name", "unknown")
103+
logger.info(f"Would have removed download: {item_name}")
104+
return
105+
106+
for item in items:
107+
item_name = item.get("name", "unknown")
108+
try:
109+
if self.download_client_type == "qbittorrent":
110+
download_hash = item["hash"]
111+
await self.download_client.remove_download(download_hash)
112+
elif self.download_client_type == "sabnzbd":
113+
nzo_id = item["nzo_id"]
114+
await self.download_client.remove_download(nzo_id)
115+
116+
logger.info(f"Removed download: {item_name}")
117+
118+
except Exception as e:
119+
logger.error(f"Failed to remove {item_name}: {e}")

0 commit comments

Comments
 (0)