Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b433e06
feat: add remove_completed job and new download client job type
Baz00k Sep 11, 2025
42d6d61
docs: update README to include details about REMOVE_COMPLETED job
Baz00k Sep 11, 2025
407ca1b
fix: skipped for usenet test attribute error
Baz00k Sep 11, 2025
c96accc
fix: loading remove_completed job settings from env
Baz00k Sep 11, 2025
c474fc9
Merge branch 'dev' into dev
Baz00k Sep 14, 2025
4df383d
Merge branch 'dev' into dev
Baz00k Sep 24, 2025
17805c5
Merge branch 'dev' into dev
Baz00k Oct 1, 2025
4360499
fix: use global limits as fallback
Baz00k Sep 29, 2025
20bc294
fix: log only if client has any jobs enabled
Baz00k Oct 3, 2025
f9eaa98
refactor: check the client against list of supported clients
Baz00k Oct 3, 2025
eb79c32
chore: add better examples and exmplanation in readme
Baz00k Oct 3, 2025
cc3db3d
chore: revert table of contents change
Baz00k Oct 3, 2025
8b7cbc9
fix: failing test after log update
Baz00k Oct 3, 2025
4a3dfd5
refactor: streamline item removal for download clients
Baz00k Oct 14, 2025
c0ac6dc
refactor: use DOWNLOAD_CLIENT_TYPES for download client iteration
Baz00k Oct 15, 2025
b8db7c1
fix: failing tests after client job base class update
Baz00k Oct 15, 2025
461f974
chore: update remove_completed job docs
Baz00k Oct 15, 2025
efe2e23
chore: improve tests reliablility by not checking log output
Baz00k Oct 15, 2025
7bb509d
Hid additional internal attributes from logging
ManiMatter Oct 19, 2025
da22973
Always putting obsolete into output, even if not used
ManiMatter Oct 19, 2025
8f1c0bb
Fixing filter of internal attributes
ManiMatter Oct 19, 2025
1858c3d
Few refinements, and added obsolete as default target tag
ManiMatter Oct 19, 2025
78ac8d4
Renamed remove_completed -> remove_done_seeding
ManiMatter Oct 19, 2025
2fb373a
pylint fix
ManiMatter Oct 19, 2025
8dab88d
Merge branch 'dev' into dev
ManiMatter Oct 24, 2025
090af35
Merge branch 'dev' into dev
ManiMatter Oct 25, 2025
98ea479
Merge branch 'dev' into dev
ManiMatter Oct 26, 2025
057cbdc
Finalized readme
ManiMatter Nov 1, 2025
b39b8d2
Merge branch 'dev' of https://github.com/Baz00k/decluttarr into pr/Ba…
ManiMatter Nov 1, 2025
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
40 changes: 35 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Looking to **upgrade from V1 to V2**? Look [here](#upgrading-from-v1-to-v2)
- [REMOVE_SLOW](#remove_slow)
- [REMOVE_STALLED](#remove_stalled)
- [REMOVE_UNMONITORED](#remove_unmonitored)
- [REMOVE_DONE_SEEDING](#remove_done_seeding)
- [SEARCH_CUTOFF_UNMET](#search_unmet_cutoff)
- [SEARCH_MISSING](#search_missing)
- [DETECT_DELETIONS](#detect_deletions)
Expand All @@ -49,8 +50,6 @@ Looking to **upgrade from V1 to V2**? Look [here](#upgrading-from-v1-to-v2)
- [WHISPARR](#whisparr)
- [Downloaders](#download-clients)
- [QBITTORRENT](#qbittorrent)
- [SABNZBD](#sabnzbd)
- [Disclaimer](#disclaimer)

## Overview

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

Expand Down Expand Up @@ -227,6 +227,11 @@ services:
# As written above, these can also be set as Job Defaults so you don't have to specify them as granular as below.
# REMOVE_BAD_FILES: |
# keep_archives: True
# REMOVE_DONE_SEEDING: |
# target_tags:
# - "Obsolete"
# target_categories:
# - "autobrr"
# REMOVE_FAILED_DOWNLOADS: True
# REMOVE_FAILED_IMPORTS: |
# message_patterns:
Expand Down Expand Up @@ -326,9 +331,9 @@ Decluttarr v2 is a major update with a cleaner config format and powerful new fe
- 🧼 **Bad files handling**: Added ability to not download potentially malicious files and files such as trailers / samples
- 🐌 **Adaptive slowness**: Slow downloads-removal can be dynamically turned on/off depending on overall bandwidth usage
- 📄 **Log files**: Logs can now be retrieved from a log file
- 📌 **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
- 🗑️ **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
- 📌 **Deletion detection**: If movies or tv shows get deleted (for instance via Plex), decluttarr can notice that and refresh the respective item

- ⛓️ **Being a good seeder**: A new job allows you to wait with the removal until your seed goals have been achieved
---

### ⚠️ Breaking Changes
Expand Down Expand Up @@ -407,7 +412,7 @@ Configures the general behavior of the application (across all features)
- Allows you to configure download client names that will be skipped by decluttarr
Note: The names provided here have to 100% match with how you have named your download clients in your *arr application(s)
- Type: List of strings
- Is Mandatory: No (Defaults to [], i.e. nothing ignored])
- Is Mandatory: No (Defaults to [], i.e. nothing ignored)

#### PRIVATE_TRACKER_HANDLING / PUBLIC_TRACKER_HANDLING

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

#### REMOVE_DONE_SEEDING

- 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).
- "Done Seeding" means that the Ratio limit or Seeding Time limit for your download has been reached
- The limits are taken from your global settings in your download client, or the download-specific overrides
- Type: Boolean or Dict
- Permissible Values:
- If Bool: True, False
- If Dict:
- `target_tags`: List of tag names to match
- `target_categories`: List of category names to match
- Matching logic:
- Requires at least one of `target_tags` or `target_categories`. If neither is provided, the configured obsolete tag will be used as target_tag
- A torrent must be completed AND match (category IN `target_categories`) OR (has any tag IN `target_tags`)
- If both tags and categories are provided, the condition is OR between them
- Is Mandatory: No (Defaults to False)
- Notes:
- This job currently only supports qBittorrent
- Works great together with `obsolete_tag`: have other jobs tag torrents (e.g., "Obsolete") and let this job remove them once completed
- Why not set "Remove torrent and its files" upon reaching seeding goals in download client?
- This setting is discouraged by *arrs and you will get warnings about it
- You get more granular control
- You can use this job to clean up after other apps like autobrr that do not have any torrent management features

#### REMOVE_FAILED_DOWNLOADS

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


#### SEARCH_UNMET_CUTOFF

- Steers whether searches are automatically triggered for items that are wanted and have not yet met the cutoff
Expand Down
5 changes: 5 additions & 0 deletions config/config_example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ job_defaults:
jobs:
remove_bad_files:
# keep_archives: true
remove_done_seeding:
# target_tags:
# - "Obsolete"
# target_categories:
# - "autobrr"
remove_failed_downloads:
remove_failed_imports:
message_patterns:
Expand Down
4 changes: 3 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,11 @@ async def main():
await job_manager.run_jobs(arr)
logger.verbose("")

# Run download client jobs (these run independently of *arr instances)
await job_manager.run_download_client_jobs()

# Wait for the next run
await wait_next_run()
return


if __name__ == "__main__":
Expand Down
64 changes: 61 additions & 3 deletions src/job_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Cleans the download queue
from src.jobs.remove_bad_files import RemoveBadFiles
from src.jobs.remove_done_seeding import RemoveDoneSeeding
from src.jobs.remove_failed_downloads import RemoveFailedDownloads
from src.jobs.remove_failed_imports import RemoveFailedImports
from src.jobs.remove_metadata_missing import RemoveMetadataMissing
Expand All @@ -9,6 +10,7 @@
from src.jobs.remove_stalled import RemoveStalled
from src.jobs.remove_unmonitored import RemoveUnmonitored
from src.jobs.search_handler import SearchHandler
from src.settings._download_clients import DOWNLOAD_CLIENT_TYPES
from src.utils.log_setup import logger
from src.utils.queue_manager import QueueManager

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

async def run_download_client_jobs(self):
"""Run jobs that operate on download clients directly."""
if not await self._download_clients_connected():
return None

items_detected = 0
for download_client_type in DOWNLOAD_CLIENT_TYPES:
download_clients = getattr(
self.settings.download_clients,
download_client_type,
[],
)

for client in download_clients:
# Get jobs for this client
download_client_jobs = self._get_download_client_jobs_for_client(
client,
download_client_type,
)

if not any(job.job.enabled for job in download_client_jobs):
continue

logger.info(
f"*** Running jobs on {client.name} ({client.base_url}) ***",
)

for download_client_job in download_client_jobs:
if download_client_job.job.enabled:
items_detected += await download_client_job.run()

return items_detected

async def removal_jobs(self):
# Check removal jobs
removal_jobs = self._get_removal_jobs()
Expand Down Expand Up @@ -72,7 +107,7 @@ async def search_jobs(self):

async def _queue_has_items(self):
logger.debug(
f"job_manager.py/_queue_has_items (Before any removal jobs): Checking if any items in full queue"
"job_manager.py/_queue_has_items (Before any removal jobs): Checking if any items in full queue",
)
queue_manager = QueueManager(self.arr, self.settings)
full_queue = await queue_manager.get_queue_items("full")
Expand All @@ -99,11 +134,11 @@ async def _download_clients_connected(self):
async def _check_client_connection_status(self, clients):
for client in clients:
logger.debug(
f"job_manager.py/_check_client_connection_status: Checking if {client.name} is connected"
f"job_manager.py/_check_client_connection_status: Checking if {client.name} is connected",
)
if not await client.check_connected():
logger.warning(
f">>> {client.name} is disconnected. Skipping queue cleaning on {self.arr.name}."
f">>> {client.name} is disconnected. Skipping queue cleaning on {self.arr.name}.",
)
return False
return True
Expand Down Expand Up @@ -133,3 +168,26 @@ def _get_removal_jobs(self):
removal_job_class(self.arr, self.settings, removal_job_name),
)
return jobs

def _get_download_client_jobs_for_client(self, client, client_type):
"""
Return a list of download client job instances for a specific download client.

Each job is included if the corresponding attribute exists and is truthy in settings.jobs.
"""
download_client_job_classes = {
"remove_done_seeding": RemoveDoneSeeding,
}

jobs = []
for job_name, job_class in download_client_job_classes.items():
if getattr(self.settings.jobs, job_name, False):
jobs.append(
job_class(
client,
client_type,
self.settings,
job_name,
),
)
return jobs
119 changes: 119 additions & 0 deletions src/jobs/download_client_removal_job.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
from abc import ABC, abstractmethod

from src.utils.log_setup import logger


class DownloadClientRemovalJob(ABC):
"""Base class for removal jobs that run on download clients directly."""

job_name = None

def __init__(
self,
download_client: object,
download_client_type: str,
settings: object,
job_name: str,
) -> None:
self.download_client = download_client
self.download_client_type = download_client_type
self.settings = settings
self.job_name = job_name
self.job = getattr(self.settings.jobs, self.job_name)

async def run(self) -> int:
"""Run the download client job."""
if not self.job.enabled:
return 0

logger.debug(
f"download_client_job.py/run: Launching job '{self.job_name}' on {self.download_client.name} "
f"({self.download_client_type})",
)

all_items = await self._get_all_items()
if not all_items:
return 0

items_to_remove = await self._get_items_to_remove(all_items)

# Filter out protected items
items_to_remove = self._filter_protected_items(items_to_remove)

if not items_to_remove:
logger.debug(f"No items to remove for job '{self.job_name}'.")
return 0

# Remove the affected items
await self._remove_items(items_to_remove)

return len(items_to_remove)

async def _get_all_items(self) -> list:
"""Get all items from the download client."""
try:
if self.download_client_type == "qbittorrent":
return await self.download_client.get_qbit_items()
if self.download_client_type == "sabnzbd":
return await self.download_client.get_history_items()
except Exception as e:
logger.error(
f"Error fetching items from {self.download_client.name}: {e}",
)
return []

def _filter_protected_items(self, items: list) -> list:
"""Filter out items that are protected by tags or categories."""
protected_tag = getattr(self.settings.general, "protected_tag", None)
if not protected_tag:
return items

filtered_items = []
for item in items:
is_protected = False
item_name = item.get("name", "unknown")
if self.download_client_type == "qbittorrent":
tags = item.get("tags", "").split(",")
tags = [tag.strip() for tag in tags if tag.strip()]
category = item.get("category", "")
if protected_tag in tags or protected_tag == category:
is_protected = True
elif self.download_client_type == "sabnzbd":
category = item.get("category", "")
if protected_tag == category:
is_protected = True

if is_protected:
logger.debug(f"Ignoring protected item: {item_name}")
else:
filtered_items.append(item)

return filtered_items

@abstractmethod
async def _get_items_to_remove(self, items: list) -> list:
"""Return a list of items to remove from the download client."""

async def _remove_items(self, items: list) -> None:
"""Remove the affected items from the download client."""
if self.settings.general.test_run:
logger.info("Test run is enabled. Skipping actual removal.")
for item in items:
item_name = item.get("name", "unknown")
logger.info(f"Would have removed download: {item_name}")
return

for item in items:
item_name = item.get("name", "unknown")
try:
if self.download_client_type == "qbittorrent":
download_hash = item["hash"]
await self.download_client.remove_download(download_hash)
elif self.download_client_type == "sabnzbd":
nzo_id = item["nzo_id"]
await self.download_client.remove_download(nzo_id)

logger.info(f"Removed download: {item_name}")

except Exception as e:
logger.error(f"Failed to remove {item_name}: {e}")
Loading