From b433e063384b7a3804e507d8fb2fb3313c98f69a Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Thu, 11 Sep 2025 02:08:56 +0200 Subject: [PATCH 01/22] feat: add remove_completed job and new download client job type --- config/config_example.yaml | 5 + main.py | 31 ++- src/job_manager.py | 67 ++++++- src/jobs/download_client_removal_job.py | 152 +++++++++++++++ src/jobs/remove_completed.py | 87 +++++++++ src/settings/_jobs.py | 4 +- tests/jobs/test_remove_completed.py | 249 ++++++++++++++++++++++++ 7 files changed, 580 insertions(+), 15 deletions(-) create mode 100644 src/jobs/download_client_removal_job.py create mode 100644 src/jobs/remove_completed.py create mode 100644 tests/jobs/test_remove_completed.py diff --git a/config/config_example.yaml b/config/config_example.yaml index 59bcb15..e1893f3 100644 --- a/config/config_example.yaml +++ b/config/config_example.yaml @@ -42,6 +42,11 @@ jobs: search_missing: # min_days_between_searches: 7 # max_concurrent_searches: 3 + remove_completed: + target_tags: + - "Obsolete" + # target_categories: + # - "autobrr" instances: sonarr: diff --git a/main.py b/main.py index 5f64dd5..0ce0f3f 100644 --- a/main.py +++ b/main.py @@ -1,22 +1,26 @@ import asyncio -import signal -import types import datetime +import signal import sys +import types +from src.deletion_handler.deletion_handler import WatcherManager from src.job_manager import JobManager from src.settings.settings import Settings from src.utils.log_setup import logger from src.utils.startup import launch_steps -from src.deletion_handler.deletion_handler import WatcherManager settings = Settings() job_manager = JobManager(settings) watch_manager = WatcherManager(settings) -def terminate(sigterm: signal.SIGTERM, frame: types.FrameType) -> None: # noqa: ARG001, pylint: disable=unused-argument - """Terminate cleanly. Needed for respecting 'docker stop'. +def terminate( + sigterm: signal.SIGTERM, + frame: types.FrameType, +) -> None: + """ + Terminate cleanly. Needed for respecting 'docker stop'. Args: ---- @@ -24,14 +28,18 @@ def terminate(sigterm: signal.SIGTERM, frame: types.FrameType) -> None: # noqa: frame: The execution frame. """ - - logger.info(f"Termination signal received at {datetime.datetime.now()}.") # noqa: DTZ005 + logger.info( + f"Termination signal received at {datetime.datetime.now()}.", + ) watch_manager.stop() sys.exit(0) + async def wait_next_run(): - # Calculate next run time dynamically (to display) - next_run = datetime.datetime.now() + datetime.timedelta(minutes=settings.general.timer) + # Calculate next run time dynamically (to display) + next_run = datetime.datetime.now() + datetime.timedelta( + minutes=settings.general.timer, + ) formatted_next_run = next_run.strftime("%Y-%m-%d %H:%M") logger.verbose(f"*** Done - Next run at {formatted_next_run} ****") @@ -39,6 +47,7 @@ async def wait_next_run(): # Wait for the next run await asyncio.sleep(settings.general.timer * 60) + # Main function async def main(): await launch_steps(settings) @@ -58,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__": diff --git a/src/job_manager.py b/src/job_manager.py index 8de4fff..7a1906f 100644 --- a/src/job_manager.py +++ b/src/job_manager.py @@ -1,5 +1,6 @@ # Cleans the download queue from src.jobs.remove_bad_files import RemoveBadFiles +from src.jobs.remove_completed import RemoveCompleted from src.jobs.remove_failed_downloads import RemoveFailedDownloads from src.jobs.remove_failed_imports import RemoveFailedImports from src.jobs.remove_metadata_missing import RemoveMetadataMissing @@ -25,6 +26,41 @@ 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 ["qbittorrent", "sabnzbd"]: + download_clients = getattr( + self.settings.download_clients, + download_client_type, + [], + ) + + for client in download_clients: + logger.info( + f"*** Running jobs on {client.name} ({client.base_url}) ***", + ) + + # 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): + logger.verbose( + "Download Client Jobs: None triggered (No jobs active)", + ) + continue + + for download_client_job in download_client_jobs: + items_detected += await download_client_job.run() + + return items_detected + async def removal_jobs(self): # Check removal jobs removal_jobs = self._get_removal_jobs() @@ -72,7 +108,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") @@ -99,10 +135,12 @@ 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}.") + logger.warning( + f">>> {client.name} is disconnected. Skipping queue cleaning on {self.arr.name}.", + ) return False return True @@ -131,3 +169,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_completed": RemoveCompleted, + } + + 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 diff --git a/src/jobs/download_client_removal_job.py b/src/jobs/download_client_removal_job.py new file mode 100644 index 0000000..6b95958 --- /dev/null +++ b/src/jobs/download_client_removal_job.py @@ -0,0 +1,152 @@ +from abc import ABC, abstractmethod + +from src.utils.common import make_request +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": + await self._remove_qbittorrent_item(item) + elif self.download_client_type == "sabnzbd": + await self._remove_sabnzbd_item(item) + + logger.info( + f"Removed download: {item_name}", + ) + + except Exception as e: + logger.error(f"Failed to remove {item_name}: {e}") + + async def _remove_qbittorrent_item(self, item: dict) -> None: + """Remove a torrent from qBittorrent.""" + download_id = item["hash"].lower() + data = { + "hashes": download_id, + "deleteFiles": "true", + } + await make_request( + "post", + f"{self.download_client.api_url}/torrents/delete", + self.settings, + data=data, + cookies=self.download_client.cookie, + ) + + async def _remove_sabnzbd_item(self, item: dict) -> None: + """Remove a download from SABnzbd history.""" + download_id = item["nzo_id"] + params = { + "mode": "history", + "name": "delete", + "value": download_id, + "apikey": self.download_client.api_key, + "output": "json", + } + await make_request( + "get", + self.download_client.api_url, + self.settings, + params=params, + ) diff --git a/src/jobs/remove_completed.py b/src/jobs/remove_completed.py new file mode 100644 index 0000000..b4a7c3e --- /dev/null +++ b/src/jobs/remove_completed.py @@ -0,0 +1,87 @@ +"""Removes completed torrents that have specific tags/categories.""" + +from src.jobs.download_client_removal_job import DownloadClientRemovalJob +from src.utils.log_setup import logger + +COMPLETED_STATES = [ + "stoppedUP", + "pausedUP", # Older qBittorrent versions +] + + +class RemoveCompleted(DownloadClientRemovalJob): + """Job to remove completed torrents that match specific tags or categories.""" + + async def run(self) -> int: + if self.download_client_type == "sabnzbd": + logger.debug( + f"Skipping job '{self.job_name}' for Usenet client {self.download_client.name}.", + ) + return 0 + return await super().run() + + async def _get_items_to_remove(self, items: list) -> list: + """ + Filters a list of items from a download client and returns those + that should be removed based on completion status and other criteria. + """ + target_tags, target_categories = self._get_targets() + + if not target_tags and not target_categories: + logger.debug( + "No target tags or categories specified for remove_completed job.", + ) + return [] + + items_to_remove = [ + item + for item in items + if self._is_completed(item) + and self._meets_target_criteria(item, target_tags, target_categories) + ] + + for item in items_to_remove: + logger.debug( + f"Found completed item to remove: {item.get('name', 'unknown')}", + ) + + return items_to_remove + + def _is_completed(self, item: dict) -> bool: + """Check if an item has met its seeding goals.""" + state = item.get("state", "") + if state not in COMPLETED_STATES: + return False + + # Additional sanity checks for ratio and seeding time + ratio = item.get("ratio", 0) + ratio_limit = item.get("ratio_limit", -1) + seeding_time = item.get("seeding_time", 0) + seeding_time_limit = item.get("seeding_time_limit", -1) + + ratio_limit_met = ratio >= ratio_limit > 0 + seeding_time_limit_met = seeding_time >= seeding_time_limit > 0 + + return ratio_limit_met or seeding_time_limit_met + + def _meets_target_criteria( + self, + item: dict, + target_tags: list, + target_categories: list, + ) -> bool: + """Check if an item has the required tags or categories for removal.""" + item_category = item.get("category", "") + if item_category in target_categories: + return True + + tags = item.get("tags", "").split(",") + item_tags = {tag.strip() for tag in tags if tag.strip()} + + return bool(item_tags.intersection(target_tags)) + + def _get_targets(self) -> tuple[list, list]: + """Get the list of tags and categories to look for from job settings.""" + tags = getattr(self.job, "target_tags", []) + categories = getattr(self.job, "target_categories", []) + return tags, categories diff --git a/src/settings/_jobs.py b/src/settings/_jobs.py index 1e17bb8..c8da1b1 100644 --- a/src/settings/_jobs.py +++ b/src/settings/_jobs.py @@ -64,7 +64,7 @@ def __init__(self, config): self.max_concurrent_searches = max_concurrent_searches else: logger.warning( - f"Job default 'max_concurrent_searches' must be an integer greater 0. Found: {str(max_concurrent_searches)}. Using default: {self.max_concurrent_searches}" + f"Job default 'max_concurrent_searches' must be an integer greater 0. Found: {max_concurrent_searches!s}. Using default: {self.max_concurrent_searches}", ) self.min_days_between_searches = job_defaults_config.get( "min_days_between_searches", @@ -84,6 +84,7 @@ def __init__(self, config): def _set_job_defaults(self): self.remove_bad_files = JobParams(keep_archives=self.job_defaults.keep_archives) + self.remove_completed = JobParams() self.remove_failed_downloads = JobParams() self.remove_failed_imports = JobParams( message_patterns=self.job_defaults.message_patterns, @@ -109,7 +110,6 @@ def _set_job_defaults(self): ) self.detect_deletions = JobParams() - def _set_job_configs(self, config): # Populate jobs from YAML config for job_name in self.__dict__: diff --git a/tests/jobs/test_remove_completed.py b/tests/jobs/test_remove_completed.py new file mode 100644 index 0000000..32df3c0 --- /dev/null +++ b/tests/jobs/test_remove_completed.py @@ -0,0 +1,249 @@ +"""Tests for the remove_completed job.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.jobs.remove_completed import COMPLETED_STATES, RemoveCompleted + + +def create_mock_settings(target_tags=None, target_categories=None): + """Create mock settings for testing.""" + settings = MagicMock() + settings.jobs = MagicMock() + settings.jobs.remove_completed.enabled = True + settings.jobs.remove_completed.target_tags = target_tags or [] + settings.jobs.remove_completed.target_categories = target_categories or [] + settings.general = MagicMock() + settings.general.protected_tag = "protected" + return settings + + +def create_mock_download_client(items: list): + """Create a mock download client.""" + client = MagicMock() + client.get_qbit_items = AsyncMock(return_value=items) + return client + + +# Default item properties for tests +ITEM_DEFAULTS = { + "progress": 1, + "ratio": 0, + "ratio_limit": -1, + "seeding_time": 0, + "seeding_time_limit": -1, + "tags": "", + "category": "movies", + "state": "stoppedUP", +} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("item_properties", "target_tags", "target_categories", "should_be_removed"), + [ + # Ratio limit met, matching tag and category + ( + {"ratio": 2, "ratio_limit": 2, "tags": "tag1"}, + ["tag1"], + ["movies"], + True, + ), + # Seeding time limit met, matching tag and category + ( + {"seeding_time": 100, "seeding_time_limit": 100, "tags": "tag1"}, + ["tag1"], + ["movies"], + True, + ), + # Neither limit met + ({"ratio": 1, "ratio_limit": 2}, ["tag1"], ["movies"], False), + # Progress less than 1 (should not be considered completed) + ( + {"progress": 0.5, "state": "downloading"}, + ["tag1"], + ["movies"], + False, + ), + # No matching tags or categories + ( + {"ratio": 2, "ratio_limit": 2, "tags": "other", "category": "tv"}, + ["tag1"], + ["movies"], + False, + ), + # Matching category, but not completed + ({"category": "tv", "state": "downloading"}, [], ["tv"], False), + # Matching tag, but not completed + ({"tags": "tag2", "state": "downloading"}, ["tag2"], [], False), + # Matching category and completed (ratio) + ( + {"ratio": 2, "ratio_limit": 2, "category": "tv"}, + [], + ["tv"], + True, + ), + # Matching tag and completed (seeding time) + ( + {"seeding_time": 100, "seeding_time_limit": 100, "tags": "tag2"}, + ["tag2"], + [], + True, + ), + # No targets specified + ({"ratio": 2, "ratio_limit": 2}, [], [], False), + # Item with multiple tags, one is a target + ( + {"tags": "tag1,tag2", "ratio": 2, "ratio_limit": 2}, + ["tag2"], + [], + True, + ), + # Item with a tag that is a substring of a target tag (should not match) + ({"tags": "tag", "ratio": 2, "ratio_limit": 2}, ["tag1"], [], False), + # Item with a category that is a substring of a target (should not match) + ( + {"category": "movie", "ratio": 2, "ratio_limit": 2}, + [], + ["movies"], + False, + ), + # Test with another completed state + ( + {"ratio": 2, "ratio_limit": 2, "state": "pausedUP"}, + ["tag1"], + ["movies"], + True, + ), + ], +) +async def test_remove_completed_logic( + item_properties: dict, + target_tags: list, + target_categories: list, + should_be_removed: bool, +): + """Test the logic of the remove_completed job with various scenarios.""" + item = {**ITEM_DEFAULTS, **item_properties, "name": "test_item"} + + settings = create_mock_settings(target_tags, target_categories) + client = create_mock_download_client([item]) + + job = RemoveCompleted(client, "qbittorrent", settings, "remove_completed") + + items_to_remove = await job._get_items_to_remove(await client.get_qbit_items()) + + if should_be_removed: + assert len(items_to_remove) == 1 + assert items_to_remove[0]["name"] == "test_item" + else: + assert len(items_to_remove) == 0 + + +@pytest.mark.asyncio +async def test_remove_completed_skipped_for_sabnzbd(): + """Test that the remove_completed job is skipped for SABnzbd clients.""" + settings = create_mock_settings() + client = create_mock_download_client([]) + job = RemoveCompleted(client, "sabnzbd", settings, "remove_completed") + + # We check the log message instead of mocking the super run + with patch.object(job.logger, "debug") as mock_log: + result = await job.run() + assert result == 0 + mock_log.assert_called_with( + "Skipping job 'remove_completed' for Usenet client mock_client_name.", + ) + + +@pytest.mark.asyncio +async def test_remove_completed_test_run_enabled(): + """Test that no items are removed when test_run is enabled.""" + item = { + **ITEM_DEFAULTS, + "ratio": 2, + "ratio_limit": 2, + "name": "test_item", + "tags": "tag1", + } + settings = create_mock_settings(target_tags=["tag1"]) + settings.general.test_run = True + client = create_mock_download_client([item]) + job = RemoveCompleted(client, "qbittorrent", settings, "remove_completed") + + with patch.object( + job, + "_remove_qbittorrent_item", + new_callable=AsyncMock, + ) as mock_remove: + result = await job.run() + + assert ( + result == 1 + ) # The job should still report the number of items it would have removed + mock_remove.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("protected_on", ["tag", "category"]) +async def test_remove_completed_with_protected_item(protected_on): + """Test that items with a protected tag or category are not removed.""" + item_properties = {"ratio": 2, "ratio_limit": 2, "name": "protected_item"} + target_tags = ["tag1"] + target_categories = ["movies"] + + if protected_on == "tag": + item_properties["tags"] = "protected" + # Also add a targetable tag to ensure it's the protection that stops it + item_properties["tags"] += ",tag1" + else: + item_properties["category"] = "protected" + + item = {**ITEM_DEFAULTS, **item_properties} + + settings = create_mock_settings( + target_tags=target_tags, + target_categories=target_categories, + ) + client = create_mock_download_client([item]) + job = RemoveCompleted(client, "qbittorrent", settings, "remove_completed") + + with patch.object( + job, + "_remove_qbittorrent_item", + new_callable=AsyncMock, + ) as mock_remove: + result = await job.run() + assert result == 0 # No items should be removed + mock_remove.assert_not_called() + + +@pytest.mark.asyncio +async def test_is_completed_logic(): + """Test the internal _is_completed logic with different states and limits.""" + job = RemoveCompleted(MagicMock(), "qbittorrent", MagicMock(), "remove_completed") + + # Completed states + for state in COMPLETED_STATES: + # Ratio met + assert job._is_completed( + {"state": state, "ratio": 2, "ratio_limit": 2}, + ), f"Failed for state {state} with ratio met" + # Seeding time met + assert job._is_completed( + {"state": state, "seeding_time": 100, "seeding_time_limit": 100}, + ), f"Failed for state {state} with seeding time met" + # Neither met + assert not job._is_completed( + {"state": state, "ratio": 1, "ratio_limit": 2}, + ), f"Failed for state {state} with neither limit met" + # Limits not set + assert not job._is_completed( + {"state": state, "ratio": 1, "ratio_limit": -1}, + ), f"Failed for state {state} with no ratio limit" + + # Non-completed states + assert not job._is_completed( + {"state": "downloading", "ratio": 2, "ratio_limit": 1}, + ), "Failed for non-completed state" From 42d6d61acacb4e2fada59a5e955417fe066116e4 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Thu, 11 Sep 2025 02:24:34 +0200 Subject: [PATCH 02/22] docs: update README to include details about REMOVE_COMPLETED job --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 7563138..ce94fec 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,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_COMPLETED](#remove_completed) - [SEARCH_CUTOFF_UNMET](#search_unmet_cutoff) - [SEARCH_MISSING](#search_missing) - [DETECT_DELETIONS](#detect_deletions) @@ -68,6 +69,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_completed) - 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) @@ -562,6 +564,17 @@ This is the interesting section. It defines which job you want decluttarr to run - Permissible Values: True, False - Is Mandatory: No (Defaults to False) +#### REMOVE_COMPLETED + +- Steers whether completed downloads are removed from your download client's queue. This is particularly useful for cleaning up torrents that have finished seeding and are no longer needed. +- Permissible Values: + - `target_tags`: A list of tags to identify completed items for removal. + - `target_categories`: A list of categories to identify completed items for removal. +- Is Mandatory: No (Defaults to False) +- Note: + - This job currently only supports qBittorrent. + - It's useful for cleaning up items that might have been tagged as "obsolete" by other jobs in this tool, once they have finished seeding or automations for boosting ratio in private trackers. + #### SEARCH_UNMET_CUTOFF - Steers whether searches are automatically triggered for items that are wanted and have not yet met the cutoff From 407ca1bf0b3a575ead84057c4cb70081553a5fbe Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Thu, 11 Sep 2025 02:26:20 +0200 Subject: [PATCH 03/22] fix: skipped for usenet test attribute error --- tests/jobs/test_remove_completed.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/jobs/test_remove_completed.py b/tests/jobs/test_remove_completed.py index 32df3c0..ae5dcbf 100644 --- a/tests/jobs/test_remove_completed.py +++ b/tests/jobs/test_remove_completed.py @@ -19,10 +19,11 @@ def create_mock_settings(target_tags=None, target_categories=None): return settings -def create_mock_download_client(items: list): +def create_mock_download_client(items, client_name="mock_client_name"): """Create a mock download client.""" client = MagicMock() client.get_qbit_items = AsyncMock(return_value=items) + client.name = client_name return client @@ -145,11 +146,11 @@ async def test_remove_completed_logic( async def test_remove_completed_skipped_for_sabnzbd(): """Test that the remove_completed job is skipped for SABnzbd clients.""" settings = create_mock_settings() - client = create_mock_download_client([]) + client = create_mock_download_client([], client_name="mock_client_name") job = RemoveCompleted(client, "sabnzbd", settings, "remove_completed") # We check the log message instead of mocking the super run - with patch.object(job.logger, "debug") as mock_log: + with patch("src.jobs.remove_completed.logger.debug") as mock_log: result = await job.run() assert result == 0 mock_log.assert_called_with( From c96accca8384023e89254e5f5afcc2ae24fec8f1 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Thu, 11 Sep 2025 10:21:05 +0200 Subject: [PATCH 04/22] fix: loading remove_completed job settings from env --- src/settings/_user_config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/settings/_user_config.py b/src/settings/_user_config.py index 3400de4..beee1b4 100644 --- a/src/settings/_user_config.py +++ b/src/settings/_user_config.py @@ -1,5 +1,6 @@ import os from pathlib import Path + import yaml from src.utils.log_setup import logger @@ -24,6 +25,7 @@ ], "jobs": [ "REMOVE_BAD_FILES", + "REMOVE_COMPLETED", "REMOVE_FAILED_DOWNLOADS", "REMOVE_FAILED_IMPORTS", "REMOVE_METADATA_MISSING", @@ -69,6 +71,7 @@ def _load_from_env() -> dict: Returns: dict: Config sections with parsed env var values. + """ config = {} From 43604999e9c45c796f6c8764362728db064b7866 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:22:09 +0200 Subject: [PATCH 05/22] fix: use global limits as fallback --- src/jobs/remove_completed.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/jobs/remove_completed.py b/src/jobs/remove_completed.py index b4a7c3e..651d2da 100644 --- a/src/jobs/remove_completed.py +++ b/src/jobs/remove_completed.py @@ -47,6 +47,13 @@ async def _get_items_to_remove(self, items: list) -> list: return items_to_remove + def _get_limit(self, item: dict, specific_key: str, global_key: str) -> float: + """Get a limit from item, falling back to a global key.""" + limit = item.get(specific_key, -1) + if limit <= 0: + limit = item.get(global_key, -1) + return limit + def _is_completed(self, item: dict) -> bool: """Check if an item has met its seeding goals.""" state = item.get("state", "") @@ -55,9 +62,14 @@ def _is_completed(self, item: dict) -> bool: # Additional sanity checks for ratio and seeding time ratio = item.get("ratio", 0) - ratio_limit = item.get("ratio_limit", -1) seeding_time = item.get("seeding_time", 0) - seeding_time_limit = item.get("seeding_time_limit", -1) + + ratio_limit = self._get_limit(item, "ratio_limit", "max_ratio") + seeding_time_limit = self._get_limit( + item, + "seeding_time_limit", + "max_seeding_time", + ) ratio_limit_met = ratio >= ratio_limit > 0 seeding_time_limit_met = seeding_time >= seeding_time_limit > 0 From 20bc2947107b865aa995436f6d7d66574a98a89e Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Fri, 3 Oct 2025 14:46:47 +0200 Subject: [PATCH 06/22] fix: log only if client has any jobs enabled --- src/job_manager.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/job_manager.py b/src/job_manager.py index 7a1906f..a1bbf3b 100644 --- a/src/job_manager.py +++ b/src/job_manager.py @@ -40,10 +40,6 @@ async def run_download_client_jobs(self): ) for client in download_clients: - logger.info( - f"*** Running jobs on {client.name} ({client.base_url}) ***", - ) - # Get jobs for this client download_client_jobs = self._get_download_client_jobs_for_client( client, @@ -51,13 +47,15 @@ async def run_download_client_jobs(self): ) if not any(job.job.enabled for job in download_client_jobs): - logger.verbose( - "Download Client Jobs: None triggered (No jobs active)", - ) continue + logger.info( + f"*** Running jobs on {client.name} ({client.base_url}) ***", + ) + for download_client_job in download_client_jobs: - items_detected += await download_client_job.run() + if download_client_job.job.enabled: + items_detected += await download_client_job.run() return items_detected From f9eaa98a2650f97b2e19eaa852666fc44ed77849 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Fri, 3 Oct 2025 14:48:22 +0200 Subject: [PATCH 07/22] refactor: check the client against list of supported clients --- src/jobs/remove_completed.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/jobs/remove_completed.py b/src/jobs/remove_completed.py index 651d2da..0fdf701 100644 --- a/src/jobs/remove_completed.py +++ b/src/jobs/remove_completed.py @@ -1,5 +1,7 @@ """Removes completed torrents that have specific tags/categories.""" +from typing import ClassVar + from src.jobs.download_client_removal_job import DownloadClientRemovalJob from src.utils.log_setup import logger @@ -12,12 +14,15 @@ class RemoveCompleted(DownloadClientRemovalJob): """Job to remove completed torrents that match specific tags or categories.""" + SUPPORTED_CLIENTS: ClassVar[list[str]] = ["qbittorrent"] + async def run(self) -> int: - if self.download_client_type == "sabnzbd": + if self.download_client_type not in self.SUPPORTED_CLIENTS: logger.debug( - f"Skipping job '{self.job_name}' for Usenet client {self.download_client.name}.", + f"remove_completed.py/run: Skipping job '{self.job_name}' for unsupported client {self.download_client.name}.", ) return 0 + return await super().run() async def _get_items_to_remove(self, items: list) -> list: From eb79c32e304b93ebbd13a246d3e5e46554c80262 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Fri, 3 Oct 2025 18:07:21 +0200 Subject: [PATCH 08/22] chore: add better examples and exmplanation in readme --- README.md | 110 +++++++++++++++++++++++++++++------------------------- 1 file changed, 59 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index f48705a..837de7d 100644 --- a/README.md +++ b/README.md @@ -5,53 +5,59 @@ _Like this app? Thanks for giving it a_ ⭐️ Looking to **upgrade from V1 to V2**? Look [here](#upgrading-from-v1-to-v2) ## Table of contents -- [Overview](#overview) -- [Dependencies & Hints & FAQ](#dependencies--hints--faq) -- [Getting started](#getting-started) - - [Running locally](#running-locally) - - [Running in docker](#running-in-docker) - - [Docker-compose with config file (recommended)](#docker-docker-compose-together-with-configyaml) - - [Docker-compose only](#docker-specifying-all-settings-in-docker-compose) - - [Config file](#config-file) -- [Upgrading from V1 to V2](#upgrading-from-v1-to-v2) -- [Explanation of the settings](#explanation-of-the-settings) - - [General](#general-settings) - - [LOG_LEVEL](#log_level) - - [TEST_RUN](#test_run) - - [TIMER](#timer) - - [SSL_VERIFICATION](#ssl_verification) - - [IGNORE_DOWNLOAD_CLIENTS](#ignore_download_clients) - - [PRIVATE_TRACKER_HANDLING / PUBLIC_TRACKER_HANDLING](#private_tracker_handling--public_tracker_handling) - - [OBSOLETE_TAG](#obsolete_tag) - - [PROTECTED_TAG](#protected_tag) - - [Job Defaults](#job-defaults) - - [MAX_STRIKES](#max_strikes) - - [MIN_DAYS_BETWEEN_SEARCHES](#min_days_between_searches) - - [MAX_CONCURRENT_SEARCHES](#max_concurrent_searches) - - [Jobs](#jobs) - - [REMOVE_BAD_FILES](#remove_bad_files) - - [REMOVE_FAILED_DOWNLOADS](#remove_failed_downloads) - - [REMOVE_FAILED_IMPORTS](#remove_failed_imports) - - [REMOVE_METADATA_MISSING](#remove_metadata_missing) - - [REMOVE_MISSING_FILES](#remove_missing_files) - - [REMOVE_ORPHANS](#remove_orphans) - - [REMOVE_SLOW](#remove_slow) - - [REMOVE_STALLED](#remove_stalled) - - [REMOVE_UNMONITORED](#remove_unmonitored) - - [REMOVE_COMPLETED](#remove_completed) - - [SEARCH_CUTOFF_UNMET](#search_unmet_cutoff) - - [SEARCH_MISSING](#search_missing) - - [DETECT_DELETIONS](#detect_deletions) - - [Instances](#arr-instances) - - [SONARR](#sonarr) - - [RADARR](#radarr) - - [READARR](#readarr) - - [LIDARR](#lidarr) - - [WHISPARR](#whisparr) - - [Downloaders](#download-clients) - - [QBITTORRENT](#qbittorrent) - - [SABNZBD](#sabnzbd) -- [Disclaimer](#disclaimer) +- [**Decluttarr**](#decluttarr) + - [Table of contents](#table-of-contents) + - [Overview](#overview) + - [Dependencies \& Hints \& FAQ](#dependencies--hints--faq) + - [Getting started](#getting-started) + - [Running locally](#running-locally) + - [Running in docker](#running-in-docker) + - [Docker: Docker-compose together with Config.yaml](#docker-docker-compose-together-with-configyaml) + - [Docker: Specifying all settings in docker-compose](#docker-specifying-all-settings-in-docker-compose) + - [Config file](#config-file) + - [Upgrading from V1 to V2](#upgrading-from-v1-to-v2) + - [Decluttarr v2 is a major update with a cleaner config format and powerful new features. Here's what changed and how to upgrade.](#decluttarr-v2-is-a-major-update-with-a-cleaner-config-format-and-powerful-new-features-heres-what-changed-and-how-to-upgrade) + - [✨ What’s New](#-whats-new) + - [⚠️ Breaking Changes](#️-breaking-changes) + - [🛠️ How to Migrate](#️-how-to-migrate) + - [Explanation of the settings](#explanation-of-the-settings) + - [**General settings**](#general-settings) + - [LOG\_LEVEL](#log_level) + - [TEST\_RUN](#test_run) + - [TIMER](#timer) + - [SSL\_VERIFICATION](#ssl_verification) + - [IGNORE\_DOWNLOAD\_CLIENTS](#ignore_download_clients) + - [PRIVATE\_TRACKER\_HANDLING / PUBLIC\_TRACKER\_HANDLING](#private_tracker_handling--public_tracker_handling) + - [OBSOLETE\_TAG](#obsolete_tag) + - [PROTECTED\_TAG](#protected_tag) + - [**Job Defaults**](#job-defaults) + - [MAX\_STRIKES](#max_strikes) + - [MIN\_DAYS\_BETWEEN\_SEARCHES](#min_days_between_searches) + - [MAX\_CONCURRENT\_SEARCHES](#max_concurrent_searches) + - [**Jobs**](#jobs) + - [REMOVE\_BAD\_FILES](#remove_bad_files) + - [REMOVE\_FAILED\_DOWNLOADS](#remove_failed_downloads) + - [REMOVE\_FAILED\_IMPORTS](#remove_failed_imports) + - [REMOVE\_METADATA\_MISSING](#remove_metadata_missing) + - [REMOVE\_MISSING\_FILES](#remove_missing_files) + - [REMOVE\_ORPHANS](#remove_orphans) + - [REMOVE\_SLOW](#remove_slow) + - [REMOVE\_STALLED](#remove_stalled) + - [REMOVE\_UNMONITORED](#remove_unmonitored) + - [REMOVE\_COMPLETED](#remove_completed) + - [SEARCH\_UNMET\_CUTOFF](#search_unmet_cutoff) + - [SEARCH\_MISSING](#search_missing) + - [DETECT\_DELETIONS](#detect_deletions) + - [**Arr Instances**](#arr-instances) + - [Radarr](#radarr) + - [Sonarr](#sonarr) + - [Readarr](#readarr) + - [Lidarr](#lidarr) + - [Whisparr](#whisparr) + - [**Download Clients**](#download-clients) + - [QBITTORRENT](#qbittorrent) + - [SABNZBD](#sabnzbd) + - [Disclaimer](#disclaimer) ## Overview @@ -588,14 +594,16 @@ This is the interesting section. It defines which job you want decluttarr to run #### REMOVE_COMPLETED -- Steers whether completed downloads are removed from your download client's queue. This is particularly useful for cleaning up torrents that have finished seeding and are no longer needed. +- Steers whether completed downloads are removed from your download client's queue. This is particularly useful for cleaning up torrents that have finished seeding (the seeding criteria for time/ratio are met) and are no longer needed. - Permissible Values: - - `target_tags`: A list of tags to identify completed items for removal. - - `target_categories`: A list of categories to identify completed items for removal. + - `target_tags`: A list of tags to identify completed items for removal. + - Example: `target_tags: ["Obsolete", "Imported"]` - removes downloads that are both completed and tagged with specified tags. The tags may be applied by other jobs, arrs or manually in the download client. + - `target_categories`: A list of categories to identify completed items for removal. + - Example: `target_categories: ["autobrr"]` - removes downloads that are both completed and belong to the specified categories. Different apps may use categories to classify downloads (e.g., "autobrr" for downloads initiated by autobrr). - Is Mandatory: No (Defaults to False) - Note: - This job currently only supports qBittorrent. - - It's useful for cleaning up items that might have been tagged as "obsolete" by other jobs in this tool, once they have finished seeding or automations for boosting ratio in private trackers. + - It's useful for cleaning up items that might have been tagged as "obsolete" by other jobs in this tool, once the trackers requirements have been met. It can also be used for automations for boosting ratio in private trackers (autobrr adds new downloads, this job removes them once they are completed). #### SEARCH_UNMET_CUTOFF From cc3db3d0bd495db2222550101fdd187bc5658e7f Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Fri, 3 Oct 2025 18:13:32 +0200 Subject: [PATCH 09/22] chore: revert table of contents change --- README.md | 98 +++++++++++++++++++++++++------------------------------ 1 file changed, 45 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 837de7d..5229f68 100644 --- a/README.md +++ b/README.md @@ -5,59 +5,51 @@ _Like this app? Thanks for giving it a_ ⭐️ Looking to **upgrade from V1 to V2**? Look [here](#upgrading-from-v1-to-v2) ## Table of contents -- [**Decluttarr**](#decluttarr) - - [Table of contents](#table-of-contents) - - [Overview](#overview) - - [Dependencies \& Hints \& FAQ](#dependencies--hints--faq) - - [Getting started](#getting-started) - - [Running locally](#running-locally) - - [Running in docker](#running-in-docker) - - [Docker: Docker-compose together with Config.yaml](#docker-docker-compose-together-with-configyaml) - - [Docker: Specifying all settings in docker-compose](#docker-specifying-all-settings-in-docker-compose) - - [Config file](#config-file) - - [Upgrading from V1 to V2](#upgrading-from-v1-to-v2) - - [Decluttarr v2 is a major update with a cleaner config format and powerful new features. Here's what changed and how to upgrade.](#decluttarr-v2-is-a-major-update-with-a-cleaner-config-format-and-powerful-new-features-heres-what-changed-and-how-to-upgrade) - - [✨ What’s New](#-whats-new) - - [⚠️ Breaking Changes](#️-breaking-changes) - - [🛠️ How to Migrate](#️-how-to-migrate) - - [Explanation of the settings](#explanation-of-the-settings) - - [**General settings**](#general-settings) - - [LOG\_LEVEL](#log_level) - - [TEST\_RUN](#test_run) - - [TIMER](#timer) - - [SSL\_VERIFICATION](#ssl_verification) - - [IGNORE\_DOWNLOAD\_CLIENTS](#ignore_download_clients) - - [PRIVATE\_TRACKER\_HANDLING / PUBLIC\_TRACKER\_HANDLING](#private_tracker_handling--public_tracker_handling) - - [OBSOLETE\_TAG](#obsolete_tag) - - [PROTECTED\_TAG](#protected_tag) - - [**Job Defaults**](#job-defaults) - - [MAX\_STRIKES](#max_strikes) - - [MIN\_DAYS\_BETWEEN\_SEARCHES](#min_days_between_searches) - - [MAX\_CONCURRENT\_SEARCHES](#max_concurrent_searches) - - [**Jobs**](#jobs) - - [REMOVE\_BAD\_FILES](#remove_bad_files) - - [REMOVE\_FAILED\_DOWNLOADS](#remove_failed_downloads) - - [REMOVE\_FAILED\_IMPORTS](#remove_failed_imports) - - [REMOVE\_METADATA\_MISSING](#remove_metadata_missing) - - [REMOVE\_MISSING\_FILES](#remove_missing_files) - - [REMOVE\_ORPHANS](#remove_orphans) - - [REMOVE\_SLOW](#remove_slow) - - [REMOVE\_STALLED](#remove_stalled) - - [REMOVE\_UNMONITORED](#remove_unmonitored) - - [REMOVE\_COMPLETED](#remove_completed) - - [SEARCH\_UNMET\_CUTOFF](#search_unmet_cutoff) - - [SEARCH\_MISSING](#search_missing) - - [DETECT\_DELETIONS](#detect_deletions) - - [**Arr Instances**](#arr-instances) - - [Radarr](#radarr) - - [Sonarr](#sonarr) - - [Readarr](#readarr) - - [Lidarr](#lidarr) - - [Whisparr](#whisparr) - - [**Download Clients**](#download-clients) - - [QBITTORRENT](#qbittorrent) - - [SABNZBD](#sabnzbd) - - [Disclaimer](#disclaimer) +- [Overview](#overview) +- [Dependencies & Hints & FAQ](#dependencies--hints--faq) +- [Getting started](#getting-started) + - [Running locally](#running-locally) + - [Running in docker](#running-in-docker) + - [Docker-compose with config file (recommended)](#docker-docker-compose-together-with-configyaml) + - [Docker-compose only](#docker-specifying-all-settings-in-docker-compose) + - [Config file](#config-file) +- [Upgrading from V1 to V2](#upgrading-from-v1-to-v2) +- [Explanation of the settings](#explanation-of-the-settings) + - [General](#general-settings) + - [LOG_LEVEL](#log_level) + - [TEST_RUN](#test_run) + - [TIMER](#timer) + - [SSL_VERIFICATION](#ssl_verification) + - [IGNORE_DOWNLOAD_CLIENTS](#ignore_download_clients) + - [PRIVATE_TRACKER_HANDLING / PUBLIC_TRACKER_HANDLING](#private_tracker_handling--public_tracker_handling) + - [OBSOLETE_TAG](#obsolete_tag) + - [PROTECTED_TAG](#protected_tag) + - [Job Defaults](#job-defaults) + - [MAX_STRIKES](#max_strikes) + - [MIN_DAYS_BETWEEN_SEARCHES](#min_days_between_searches) + - [MAX_CONCURRENT_SEARCHES](#max_concurrent_searches) + - [Jobs](#jobs) + - [REMOVE_BAD_FILES](#remove_bad_files) + - [REMOVE_FAILED_DOWNLOADS](#remove_failed_downloads) + - [REMOVE_FAILED_IMPORTS](#remove_failed_imports) + - [REMOVE_METADATA_MISSING](#remove_metadata_missing) + - [REMOVE_MISSING_FILES](#remove_missing_files) + - [REMOVE_ORPHANS](#remove_orphans) + - [REMOVE_SLOW](#remove_slow) + - [REMOVE_STALLED](#remove_stalled) + - [REMOVE_UNMONITORED](#remove_unmonitored) + - [REMOVE_COMPLETED](#remove_completed) + - [SEARCH_CUTOFF_UNMET](#search_unmet_cutoff) + - [SEARCH_MISSING](#search_missing) + - [DETECT_DELETIONS](#detect_deletions) + - [Instances](#arr-instances) + - [SONARR](#sonarr) + - [RADARR](#radarr) + - [READARR](#readarr) + - [LIDARR](#lidarr) + - [WHISPARR](#whisparr) + - [Downloaders](#download-clients) + - [QBITTORRENT](#qbittorrent) ## Overview From 8b7cbc9bcfa50f6c309a8e5483cec7e74a5e056e Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Fri, 3 Oct 2025 18:29:30 +0200 Subject: [PATCH 10/22] fix: failing test after log update --- tests/jobs/test_remove_completed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/jobs/test_remove_completed.py b/tests/jobs/test_remove_completed.py index ae5dcbf..bada544 100644 --- a/tests/jobs/test_remove_completed.py +++ b/tests/jobs/test_remove_completed.py @@ -154,7 +154,7 @@ async def test_remove_completed_skipped_for_sabnzbd(): result = await job.run() assert result == 0 mock_log.assert_called_with( - "Skipping job 'remove_completed' for Usenet client mock_client_name.", + "remove_completed.py/run: Skipping job 'remove_completed' for unsupported client mock_client_name.", ) From 4a3dfd5ba8fc42188823741008846277b570209b Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Tue, 14 Oct 2025 17:42:22 +0200 Subject: [PATCH 11/22] refactor: streamline item removal for download clients --- src/jobs/download_client_removal_job.py | 43 +++-------------------- src/settings/_download_clients_qbit.py | 46 ++++++++++++++++++------- 2 files changed, 38 insertions(+), 51 deletions(-) diff --git a/src/jobs/download_client_removal_job.py b/src/jobs/download_client_removal_job.py index 6b95958..a6ad8ca 100644 --- a/src/jobs/download_client_removal_job.py +++ b/src/jobs/download_client_removal_job.py @@ -1,6 +1,5 @@ from abc import ABC, abstractmethod -from src.utils.common import make_request from src.utils.log_setup import logger @@ -108,45 +107,13 @@ async def _remove_items(self, items: list) -> None: item_name = item.get("name", "unknown") try: if self.download_client_type == "qbittorrent": - await self._remove_qbittorrent_item(item) + download_hash = item["hash"] + await self.download_client.remove_download(download_hash) elif self.download_client_type == "sabnzbd": - await self._remove_sabnzbd_item(item) + nzo_id = item["nzo_id"] + await self.download_client.remove_download(nzo_id) - logger.info( - f"Removed download: {item_name}", - ) + logger.info(f"Removed download: {item_name}") except Exception as e: logger.error(f"Failed to remove {item_name}: {e}") - - async def _remove_qbittorrent_item(self, item: dict) -> None: - """Remove a torrent from qBittorrent.""" - download_id = item["hash"].lower() - data = { - "hashes": download_id, - "deleteFiles": "true", - } - await make_request( - "post", - f"{self.download_client.api_url}/torrents/delete", - self.settings, - data=data, - cookies=self.download_client.cookie, - ) - - async def _remove_sabnzbd_item(self, item: dict) -> None: - """Remove a download from SABnzbd history.""" - download_id = item["nzo_id"] - params = { - "mode": "history", - "name": "delete", - "value": download_id, - "apikey": self.download_client.api_key, - "output": "json", - } - await make_request( - "get", - self.download_client.api_url, - self.settings, - params=params, - ) diff --git a/src/settings/_download_clients_qbit.py b/src/settings/_download_clients_qbit.py index 9332f22..ddcb74a 100644 --- a/src/settings/_download_clients_qbit.py +++ b/src/settings/_download_clients_qbit.py @@ -61,7 +61,7 @@ def __init__( self.name = name if not self.name: logger.verbose( - "No name provided for qbittorrent client, assuming 'qBitorrent'. If the name used in your *arr is different, please correct either the name in your *arr, or set the name in your config" + "No name provided for qbittorrent client, assuming 'qBitorrent'. If the name used in your *arr is different, please correct either the name in your *arr, or set the name in your config", ) self.name = "qBittorrent" @@ -82,7 +82,7 @@ def _connection_error(): try: logger.debug( - "_download_clients_qBit.py/refresh_cookie: Refreshing qBit cookie" + "_download_clients_qBit.py/refresh_cookie: Refreshing qBit cookie", ) endpoint = f"{self.api_url}/auth/login" data = { @@ -113,11 +113,14 @@ async def fetch_version(self): logger.debug("_download_clients_qBit.py/fetch_version: Getting qBit Version") endpoint = f"{self.api_url}/app/version" response = await make_request( - "get", endpoint, self.settings, cookies=self.cookie + "get", + endpoint, + self.settings, + cookies=self.cookie, ) self.version = response.text[1:] # Remove the '_v' prefix logger.debug( - f"_download_clients_qBit.py/fetch_version: qBit version={self.version}" + f"_download_clients_qBit.py/fetch_version: qBit version={self.version}", ) async def validate_version(self): @@ -138,7 +141,7 @@ async def validate_version(self): async def create_tag(self, tag: str): """Ensure a tag exists in qBittorrent; create it if it doesn't.""" logger.debug( - "_download_clients_qBit.py/create_tag: Checking if tag '{tag}' exists (and creating it if not)" + "_download_clients_qBit.py/create_tag: Checking if tag '{tag}' exists (and creating it if not)", ) url = f"{self.api_url}/torrents/tags" response = await make_request("get", url, self.settings, cookies=self.cookie) @@ -169,7 +172,7 @@ async def set_unwanted_folder(self): """Set the 'unwanted folder' setting in qBittorrent if needed.""" if self.settings.jobs.remove_bad_files: logger.debug( - "_download_clients_qBit.py/set_unwanted_folder: Checking preferences and setting use_unwanted_folder if not already set" + "_download_clients_qBit.py/set_unwanted_folder: Checking preferences and setting use_unwanted_folder if not already set", ) endpoint = f"{self.api_url}/app/preferences" response = await make_request( @@ -197,7 +200,7 @@ async def check_qbit_reachability(self): """Check if the qBittorrent URL is reachable.""" try: logger.debug( - "_download_clients_qBit.py/check_qbit_reachability: Checking if qbit is reachable" + "_download_clients_qBit.py/check_qbit_reachability: Checking if qbit is reachable", ) endpoint = f"{self.api_url}/auth/login" data = { @@ -223,7 +226,7 @@ async def check_qbit_reachability(self): async def check_connected(self): """Check if the qBittorrent is connected to internet.""" logger.debug( - "_download_clients_qBit.py/check_qbit_reachability: Checking if qbit is connected to the internet" + "_download_clients_qBit.py/check_qbit_reachability: Checking if qbit is connected to the internet", ) qbit_connection_status = ( ( @@ -268,7 +271,7 @@ async def get_protected_and_private(self): # Fetch all torrents logger.debug( - "_download_clients_qBit/get_protected_and_private: Checking if torrents have protected tag" + "_download_clients_qBit/get_protected_and_private: Checking if torrents have protected tag", ) qbit_items = await self.get_qbit_items() @@ -287,7 +290,7 @@ async def get_protected_and_private(self): private_downloads.append(qbit_item["hash"].upper()) else: logger.debug( - "_download_clients_qBit/get_protected_and_private: Checking if torrents are private (only done for old qbit versions)" + "_download_clients_qBit/get_protected_and_private: Checking if torrents are private (only done for old qbit versions)", ) qbit_item_props = await make_request( "get", @@ -325,7 +328,7 @@ async def set_tag(self, tags, hashes): tags_str = ",".join(tags) logger.debug( - "_download_clients_qBit/set_tag: Setting tag(s) {tags_str} to {hashes_str}" + "_download_clients_qBit/set_tag: Setting tag(s) {tags_str} to {hashes_str}", ) # Prepare the data for the request @@ -374,7 +377,7 @@ async def get_torrent_files(self, download_id): async def set_torrent_file_priority(self, download_id, file_id, priority=0): logger.debug( - "_download_clients_qBit/set_torrent_file_priority: Setting download priority for torrent file" + "_download_clients_qBit/set_torrent_file_priority: Setting download priority for torrent file", ) data = { "hash": download_id.lower(), @@ -416,5 +419,22 @@ async def warn_no_bandwidth_limit_set(self): "💡 Tip: No global download speed limit is set in your qBittorrent instance. " "If you configure one, the 'remove_slow' check will automatically disable itself " "when your bandwidth is fully utilized. This prevents slow downloads from being mistakenly removed — " - "not because they lack seeds, but because your own download capacity is saturated." + "not because they lack seeds, but because your own download capacity is saturated.", ) + + async def remove_download(self, download_hash: str, delete_files: bool = True): + """Remove a torrent from qBittorrent.""" + logger.debug( + f"_download_clients_qBit/remove_download: Removing torrent {download_hash}", + ) + data = { + "hashes": download_hash.lower(), + "deleteFiles": "true" if delete_files else "false", + } + await make_request( + "post", + f"{self.api_url}/torrents/delete", + self.settings, + data=data, + cookies=self.cookie, + ) From c0ac6dc302c90578e04ef0530804252dabdff1a7 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:00:37 +0200 Subject: [PATCH 12/22] refactor: use DOWNLOAD_CLIENT_TYPES for download client iteration --- src/job_manager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/job_manager.py b/src/job_manager.py index a1bbf3b..b050ae4 100644 --- a/src/job_manager.py +++ b/src/job_manager.py @@ -10,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 @@ -32,7 +33,7 @@ async def run_download_client_jobs(self): return None items_detected = 0 - for download_client_type in ["qbittorrent", "sabnzbd"]: + for download_client_type in DOWNLOAD_CLIENT_TYPES: download_clients = getattr( self.settings.download_clients, download_client_type, From b8db7c1ca34a1fe9174d4727574d2fb8efd61ff1 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:06:18 +0200 Subject: [PATCH 13/22] fix: failing tests after client job base class update --- tests/jobs/test_remove_completed.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/jobs/test_remove_completed.py b/tests/jobs/test_remove_completed.py index bada544..62c10f6 100644 --- a/tests/jobs/test_remove_completed.py +++ b/tests/jobs/test_remove_completed.py @@ -175,7 +175,7 @@ async def test_remove_completed_test_run_enabled(): with patch.object( job, - "_remove_qbittorrent_item", + "_remove_items", new_callable=AsyncMock, ) as mock_remove: result = await job.run() @@ -200,6 +200,7 @@ async def test_remove_completed_with_protected_item(protected_on): item_properties["tags"] += ",tag1" else: item_properties["category"] = "protected" + target_categories = ["protected"] item = {**ITEM_DEFAULTS, **item_properties} @@ -212,7 +213,7 @@ async def test_remove_completed_with_protected_item(protected_on): with patch.object( job, - "_remove_qbittorrent_item", + "_remove_items", new_callable=AsyncMock, ) as mock_remove: result = await job.run() From 461f974e8ac22fa3d8983833b72a6654f40baee9 Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:31:52 +0200 Subject: [PATCH 14/22] chore: update remove_completed job docs --- README.md | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5229f68..82a258c 100644 --- a/README.md +++ b/README.md @@ -586,16 +586,27 @@ This is the interesting section. It defines which job you want decluttarr to run #### REMOVE_COMPLETED -- Steers whether completed downloads are removed from your download client's queue. This is particularly useful for cleaning up torrents that have finished seeding (the seeding criteria for time/ratio are met) and are no longer needed. +- Removes completed downloads from the download client's queue when they meet your selection criteria (tags and/or categories). +- What "completed" means: + - Downloads are considered completed when the seeding goals configured in your download client are met: either the ratio limit or the seeding time limit (per-torrent overrides or the global limits). +- Type: Boolean or Dict - Permissible Values: - - `target_tags`: A list of tags to identify completed items for removal. - - Example: `target_tags: ["Obsolete", "Imported"]` - removes downloads that are both completed and tagged with specified tags. The tags may be applied by other jobs, arrs or manually in the download client. - - `target_categories`: A list of categories to identify completed items for removal. - - Example: `target_categories: ["autobrr"]` - removes downloads that are both completed and belong to the specified categories. Different apps may use categories to classify downloads (e.g., "autobrr" for downloads initiated by autobrr). + - 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, nothing will be removed. + - 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) -- Note: +- Notes: - This job currently only supports qBittorrent. - - It's useful for cleaning up items that might have been tagged as "obsolete" by other jobs in this tool, once the trackers requirements have been met. It can also be used for automations for boosting ratio in private trackers (autobrr adds new downloads, this job removes them once they are completed). + - 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. #### SEARCH_UNMET_CUTOFF From efe2e239b134cc99b18e36fba815755097dab32a Mon Sep 17 00:00:00 2001 From: Jakub Buzuk <61548378+Baz00k@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:11:42 +0200 Subject: [PATCH 15/22] chore: improve tests reliablility by not checking log output --- tests/jobs/test_remove_completed.py | 30 ++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/jobs/test_remove_completed.py b/tests/jobs/test_remove_completed.py index 62c10f6..ce2f849 100644 --- a/tests/jobs/test_remove_completed.py +++ b/tests/jobs/test_remove_completed.py @@ -149,18 +149,17 @@ async def test_remove_completed_skipped_for_sabnzbd(): client = create_mock_download_client([], client_name="mock_client_name") job = RemoveCompleted(client, "sabnzbd", settings, "remove_completed") - # We check the log message instead of mocking the super run - with patch("src.jobs.remove_completed.logger.debug") as mock_log: - result = await job.run() - assert result == 0 - mock_log.assert_called_with( - "remove_completed.py/run: Skipping job 'remove_completed' for unsupported client mock_client_name.", - ) + # Test that the job returns 0 for unsupported clients + result = await job.run() + assert result == 0 + + # Verify that no client methods were called since the job should be skipped + client.get_qbit_items.assert_not_called() @pytest.mark.asyncio async def test_remove_completed_test_run_enabled(): - """Test that no items are removed when test_run is enabled.""" + """Test that no items are actually removed when test_run is enabled.""" item = { **ITEM_DEFAULTS, "ratio": 2, @@ -174,16 +173,17 @@ async def test_remove_completed_test_run_enabled(): job = RemoveCompleted(client, "qbittorrent", settings, "remove_completed") with patch.object( - job, - "_remove_items", + client, + "remove_download", new_callable=AsyncMock, - ) as mock_remove: + ) as mock_client_remove: result = await job.run() - assert ( - result == 1 - ) # The job should still report the number of items it would have removed - mock_remove.assert_not_called() + # The job should still report the number of items it would have removed + assert result == 1 + + # But no actual removal should occur on the client + mock_client_remove.assert_not_called() @pytest.mark.asyncio From 7bb509d66c6732025850e34a894f7b438a96da4f Mon Sep 17 00:00:00 2001 From: Benjamin Harder Date: Sun, 19 Oct 2025 16:58:28 +0200 Subject: [PATCH 16/22] Hid additional internal attributes from logging --- src/settings/_instances.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/settings/_instances.py b/src/settings/_instances.py index f17e0cd..69078ff 100644 --- a/src/settings/_instances.py +++ b/src/settings/_instances.py @@ -75,6 +75,9 @@ def config_as_yaml(self, *, hide_internal_attr=True): "detail_item_id_key", "detail_item_ids_key", "detail_item_search_command", + "refresh_item_key", + "refresh_item_id_key", + "refresh_item_command", } outputs = [] From da22973ee4aaa27c525a8febdfb33090bb2081af Mon Sep 17 00:00:00 2001 From: Benjamin Harder Date: Sun, 19 Oct 2025 18:15:47 +0200 Subject: [PATCH 17/22] Always putting obsolete into output, even if not used --- src/settings/_general.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/settings/_general.py b/src/settings/_general.py index ebcbd2f..2c0801d 100644 --- a/src/settings/_general.py +++ b/src/settings/_general.py @@ -15,7 +15,7 @@ class General: ignored_download_clients: list = [] private_tracker_handling: str = "remove" public_tracker_handling: str = "remove" - obsolete_tag: str = None + obsolete_tag: str = "Obsolete" protected_tag: str = "Keep" def __init__(self, config): @@ -46,7 +46,6 @@ def __init__(self, config): self.public_tracker_handling = self._validate_tracker_handling( self.public_tracker_handling, "public_tracker_handling" ) - self.obsolete_tag = self._determine_obsolete_tag(self.obsolete_tag) validate_data_types(self) self._remove_none_attributes() @@ -67,17 +66,10 @@ def _validate_tracker_handling(value, field_name) -> str: return "remove" return value - def _determine_obsolete_tag(self, obsolete_tag): - """Set obsolete tag to "obsolete", only if none is provided and the tag is needed for handling.""" - if obsolete_tag is None and ( - self.private_tracker_handling == "obsolete_tag" - or self.public_tracker_handling == "obsolete_tag" - ): - return "Obsolete" - return obsolete_tag - def config_as_yaml(self): """Log all general settings.""" - return get_config_as_yaml( + config_yaml = get_config_as_yaml( vars(self), ) + return config_yaml + From 8f1c0bb3df7f1ce1b020e9ca05b4a138ae781517 Mon Sep 17 00:00:00 2001 From: Benjamin Harder Date: Sun, 19 Oct 2025 18:16:31 +0200 Subject: [PATCH 18/22] Fixing filter of internal attributes --- src/settings/_config_as_yaml.py | 21 +++++++++++---------- src/settings/_constants.py | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/settings/_config_as_yaml.py b/src/settings/_config_as_yaml.py index b6570e6..95e55cf 100644 --- a/src/settings/_config_as_yaml.py +++ b/src/settings/_config_as_yaml.py @@ -17,10 +17,10 @@ def filter_internal_attributes(data, internal_attributes, hide_internal_attr): def clean_dict(data, sensitive_attributes, internal_attributes, hide_internal_attr): """Clean a dictionary by masking sensitive attributes and filtering internal ones.""" - cleaned = { + masked = { k: mask_sensitive_value(v, k, sensitive_attributes) for k, v in data.items() } - return filter_internal_attributes(cleaned, internal_attributes, hide_internal_attr) + return filter_internal_attributes(masked, internal_attributes, hide_internal_attr) def clean_list(obj, sensitive_attributes, internal_attributes, hide_internal_attr): @@ -57,7 +57,8 @@ def clean_object(obj, sensitive_attributes, internal_attributes, hide_internal_a return clean_dict( vars(obj), sensitive_attributes, internal_attributes, hide_internal_attr ) - return mask_sensitive_value(obj, "", sensitive_attributes) + + return obj def get_config_as_yaml( @@ -92,13 +93,13 @@ def get_config_as_yaml( # Process dict or class-like object config else: - cleaned_obj = clean_object( - obj, - sensitive_attributes, - internal_attributes, - hide_internal_attr, - ) - if cleaned_obj: + if not key in internal_attributes and hide_internal_attr: + cleaned_obj = clean_object( + obj, + sensitive_attributes, + internal_attributes, + hide_internal_attr, + ) config_output[key] = cleaned_obj return yaml.dump( diff --git a/src/settings/_constants.py b/src/settings/_constants.py index d9c59f0..904560e 100644 --- a/src/settings/_constants.py +++ b/src/settings/_constants.py @@ -11,7 +11,7 @@ def __init__(self): self.use_config_yaml = False # Overwritten later if config file exists def config_as_yaml(self): - return get_config_as_yaml(self.__dict__) + return get_config_as_yaml(vars(self), internal_attributes={"in_docker"}) class Paths: From 1858c3d8bfd0f90ad8dcf4a3d064c27f29479508 Mon Sep 17 00:00:00 2001 From: Benjamin Harder Date: Sun, 19 Oct 2025 18:32:40 +0200 Subject: [PATCH 19/22] Few refinements, and added obsolete as default target tag --- README.md | 58 ++++++++++++++++++++---------------- config/config_example.yaml | 10 +++---- src/jobs/remove_completed.py | 4 +-- src/settings/_jobs.py | 13 +++++--- src/settings/settings.py | 2 +- 5 files changed, 49 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 82a258c..f36f398 100644 --- a/README.md +++ b/README.md @@ -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_COMPLETED: | + # target_tags: + # - "Obsolete" + # target_categories: + # - "autobrr" # REMOVE_FAILED_DOWNLOADS: True # REMOVE_FAILED_IMPORTS: | # message_patterns: @@ -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 @@ -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 @@ -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_COMPLETED + +- Removes completed downloads from the download client's queue when they meet your selection criteria (tags and/or categories). +- What "completed" means: + - Downloads are considered completed when the seeding goals configured in your download client are met: either the ratio limit or the seeding time limit (per-torrent overrides or the global limits). +- 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 @@ -584,29 +613,6 @@ This is the interesting section. It defines which job you want decluttarr to run - Permissible Values: True, False - Is Mandatory: No (Defaults to False) -#### REMOVE_COMPLETED - -- Removes completed downloads from the download client's queue when they meet your selection criteria (tags and/or categories). -- What "completed" means: - - Downloads are considered completed when the seeding goals configured in your download client are met: either the ratio limit or the seeding time limit (per-torrent overrides or the global limits). -- 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, nothing will be removed. - - 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. #### SEARCH_UNMET_CUTOFF diff --git a/config/config_example.yaml b/config/config_example.yaml index e1893f3..317d659 100644 --- a/config/config_example.yaml +++ b/config/config_example.yaml @@ -17,6 +17,11 @@ job_defaults: jobs: remove_bad_files: # keep_archives: true + remove_completed: + # target_tags: + # - "Obsolete" + # target_categories: + # - "autobrr" remove_failed_downloads: remove_failed_imports: message_patterns: @@ -42,11 +47,6 @@ jobs: search_missing: # min_days_between_searches: 7 # max_concurrent_searches: 3 - remove_completed: - target_tags: - - "Obsolete" - # target_categories: - # - "autobrr" instances: sonarr: diff --git a/src/jobs/remove_completed.py b/src/jobs/remove_completed.py index 0fdf701..d1e8ae2 100644 --- a/src/jobs/remove_completed.py +++ b/src/jobs/remove_completed.py @@ -34,7 +34,7 @@ async def _get_items_to_remove(self, items: list) -> list: if not target_tags and not target_categories: logger.debug( - "No target tags or categories specified for remove_completed job.", + "remove_completed.py/_get_items_to_remove: No target tags or categories specified for remove_completed job.", ) return [] @@ -47,7 +47,7 @@ async def _get_items_to_remove(self, items: list) -> list: for item in items_to_remove: logger.debug( - f"Found completed item to remove: {item.get('name', 'unknown')}", + f"remove_completed.py/_get_items_to_remove: Found completed item to remove: {item.get('name', 'unknown')}", ) return items_to_remove diff --git a/src/settings/_jobs.py b/src/settings/_jobs.py index c8da1b1..e480434 100644 --- a/src/settings/_jobs.py +++ b/src/settings/_jobs.py @@ -13,6 +13,7 @@ class JobParams: min_speed: int max_concurrent_searches: int min_days_between_searches: int + target_tags: list def __init__( self, @@ -23,6 +24,7 @@ def __init__( min_speed=None, max_concurrent_searches=None, min_days_between_searches=None, + target_tags=None, ): self.enabled = enabled self.keep_archives = keep_archives @@ -31,6 +33,7 @@ def __init__( self.min_speed = min_speed self.max_concurrent_searches = max_concurrent_searches self.min_days_between_searches = min_days_between_searches + self.target_tags = target_tags # if not self.max_concurrent_searches <= 0: # logger.warning(f"Job setting 'max_concurrent_searches' must be an integer greater 0. Found: {str(self.max_concurrent_searches)}. Using default: 3") @@ -55,9 +58,11 @@ class JobDefaults: min_days_between_searches: int = 7 min_speed: int = 100 message_patterns = ["*"] + target_tags = [] - def __init__(self, config): + def __init__(self, config, settings): job_defaults_config = config.get("job_defaults", {}) + self.target_tags.append(settings.general.obsolete_tag) self.max_strikes = job_defaults_config.get("max_strikes", self.max_strikes) max_concurrent_searches = job_defaults_config.get("max_concurrent_searches") if isinstance(max_concurrent_searches, int) and max_concurrent_searches > 0: @@ -76,15 +81,15 @@ def __init__(self, config): class Jobs: """Represent all jobs explicitly.""" - def __init__(self, config): - self.job_defaults = JobDefaults(config) + def __init__(self, config, settings): + self.job_defaults = JobDefaults(config, settings) self._set_job_defaults() self._set_job_configs(config) del self.job_defaults def _set_job_defaults(self): self.remove_bad_files = JobParams(keep_archives=self.job_defaults.keep_archives) - self.remove_completed = JobParams() + self.remove_completed = JobParams(target_tags=self.job_defaults.target_tags) self.remove_failed_downloads = JobParams() self.remove_failed_imports = JobParams( message_patterns=self.job_defaults.message_patterns, diff --git a/src/settings/settings.py b/src/settings/settings.py index f60e66b..e13a143 100644 --- a/src/settings/settings.py +++ b/src/settings/settings.py @@ -16,7 +16,7 @@ def __init__(self): self.envs = Envs() config = get_user_config(self) self.general = General(config) - self.jobs = Jobs(config) + self.jobs = Jobs(config, self) self.download_clients = DownloadClients(config, self) self.instances = ArrInstances(config, self) configure_logging(self) From 78ac8d486071181a4ca06babdd3a774da8c95f23 Mon Sep 17 00:00:00 2001 From: Benjamin Harder Date: Sun, 19 Oct 2025 18:46:34 +0200 Subject: [PATCH 20/22] Renamed remove_completed -> remove_done_seeding --- README.md | 15 ++++---- config/config_example.yaml | 2 +- src/job_manager.py | 4 +-- ...ve_completed.py => remove_done_seeding.py} | 8 ++--- src/settings/_jobs.py | 2 +- src/settings/_user_config.py | 2 +- tests/jobs/test_remove_completed.py | 34 ++++++++++--------- 7 files changed, 35 insertions(+), 32 deletions(-) rename src/jobs/{remove_completed.py => remove_done_seeding.py} (87%) diff --git a/README.md b/README.md index f36f398..546665e 100644 --- a/README.md +++ b/README.md @@ -38,7 +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_COMPLETED](#remove_completed) + - [REMOVE_DONE_SEEDING](#remove_done_seeding) - [SEARCH_CUTOFF_UNMET](#search_unmet_cutoff) - [SEARCH_MISSING](#search_missing) - [DETECT_DELETIONS](#detect_deletions) @@ -68,7 +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_completed) +- 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) @@ -227,7 +227,7 @@ 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_COMPLETED: | + # REMOVE_DONE_SEEDING: | # target_tags: # - "Obsolete" # target_categories: @@ -501,11 +501,12 @@ 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_COMPLETED +#### REMOVE_DONE_SEEDING -- Removes completed downloads from the download client's queue when they meet your selection criteria (tags and/or categories). -- What "completed" means: - - Downloads are considered completed when the seeding goals configured in your download client are met: either the ratio limit or the seeding time limit (per-torrent overrides or the global limits). +- 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 the seeding goals configured in your download client are met: + - Either the ratio limit is reached + - Or the seeding time limit (per-torrent overrides or the global limits). - Type: Boolean or Dict - Permissible Values: - If Bool: True, False diff --git a/config/config_example.yaml b/config/config_example.yaml index 317d659..71ec755 100644 --- a/config/config_example.yaml +++ b/config/config_example.yaml @@ -17,7 +17,7 @@ job_defaults: jobs: remove_bad_files: # keep_archives: true - remove_completed: + remove_done_seeding: # target_tags: # - "Obsolete" # target_categories: diff --git a/src/job_manager.py b/src/job_manager.py index b050ae4..978d10b 100644 --- a/src/job_manager.py +++ b/src/job_manager.py @@ -1,6 +1,6 @@ # Cleans the download queue from src.jobs.remove_bad_files import RemoveBadFiles -from src.jobs.remove_completed import RemoveCompleted +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 @@ -176,7 +176,7 @@ def _get_download_client_jobs_for_client(self, client, client_type): Each job is included if the corresponding attribute exists and is truthy in settings.jobs. """ download_client_job_classes = { - "remove_completed": RemoveCompleted, + "remove_done_seeding": RemoveDoneSeeding, } jobs = [] diff --git a/src/jobs/remove_completed.py b/src/jobs/remove_done_seeding.py similarity index 87% rename from src/jobs/remove_completed.py rename to src/jobs/remove_done_seeding.py index d1e8ae2..e83541f 100644 --- a/src/jobs/remove_completed.py +++ b/src/jobs/remove_done_seeding.py @@ -11,7 +11,7 @@ ] -class RemoveCompleted(DownloadClientRemovalJob): +class RemoveDoneSeeding(DownloadClientRemovalJob): """Job to remove completed torrents that match specific tags or categories.""" SUPPORTED_CLIENTS: ClassVar[list[str]] = ["qbittorrent"] @@ -19,7 +19,7 @@ class RemoveCompleted(DownloadClientRemovalJob): async def run(self) -> int: if self.download_client_type not in self.SUPPORTED_CLIENTS: logger.debug( - f"remove_completed.py/run: Skipping job '{self.job_name}' for unsupported client {self.download_client.name}.", + f"remove_done_seeding.py/run: Skipping job '{self.job_name}' for unsupported client {self.download_client.name}.", ) return 0 @@ -34,7 +34,7 @@ async def _get_items_to_remove(self, items: list) -> list: if not target_tags and not target_categories: logger.debug( - "remove_completed.py/_get_items_to_remove: No target tags or categories specified for remove_completed job.", + "remove_done_seeding.py/_get_items_to_remove: No target tags or categories specified for remove_done_seeding job.", ) return [] @@ -47,7 +47,7 @@ async def _get_items_to_remove(self, items: list) -> list: for item in items_to_remove: logger.debug( - f"remove_completed.py/_get_items_to_remove: Found completed item to remove: {item.get('name', 'unknown')}", + f"remove_done_seeding.py/_get_items_to_remove: Found completed item to remove: {item.get('name', 'unknown')}", ) return items_to_remove diff --git a/src/settings/_jobs.py b/src/settings/_jobs.py index e480434..41f3994 100644 --- a/src/settings/_jobs.py +++ b/src/settings/_jobs.py @@ -89,7 +89,7 @@ def __init__(self, config, settings): def _set_job_defaults(self): self.remove_bad_files = JobParams(keep_archives=self.job_defaults.keep_archives) - self.remove_completed = JobParams(target_tags=self.job_defaults.target_tags) + self.remove_done_seeding = JobParams(target_tags=self.job_defaults.target_tags) self.remove_failed_downloads = JobParams() self.remove_failed_imports = JobParams( message_patterns=self.job_defaults.message_patterns, diff --git a/src/settings/_user_config.py b/src/settings/_user_config.py index bb1f2b8..6cc4ac8 100644 --- a/src/settings/_user_config.py +++ b/src/settings/_user_config.py @@ -28,7 +28,7 @@ ], "jobs": [ "REMOVE_BAD_FILES", - "REMOVE_COMPLETED", + "REMOVE_DONE_SEEDING", "REMOVE_FAILED_DOWNLOADS", "REMOVE_FAILED_IMPORTS", "REMOVE_METADATA_MISSING", diff --git a/tests/jobs/test_remove_completed.py b/tests/jobs/test_remove_completed.py index ce2f849..4e6db2b 100644 --- a/tests/jobs/test_remove_completed.py +++ b/tests/jobs/test_remove_completed.py @@ -1,19 +1,19 @@ -"""Tests for the remove_completed job.""" +"""Tests for the remove_done_seeding job.""" from unittest.mock import AsyncMock, MagicMock, patch import pytest -from src.jobs.remove_completed import COMPLETED_STATES, RemoveCompleted +from src.jobs.remove_done_seeding import COMPLETED_STATES, RemoveDoneSeeding def create_mock_settings(target_tags=None, target_categories=None): """Create mock settings for testing.""" settings = MagicMock() settings.jobs = MagicMock() - settings.jobs.remove_completed.enabled = True - settings.jobs.remove_completed.target_tags = target_tags or [] - settings.jobs.remove_completed.target_categories = target_categories or [] + settings.jobs.remove_done_seeding.enabled = True + settings.jobs.remove_done_seeding.target_tags = target_tags or [] + settings.jobs.remove_done_seeding.target_categories = target_categories or [] settings.general = MagicMock() settings.general.protected_tag = "protected" return settings @@ -119,19 +119,19 @@ def create_mock_download_client(items, client_name="mock_client_name"): ), ], ) -async def test_remove_completed_logic( +async def test_remove_done_seeding_logic( item_properties: dict, target_tags: list, target_categories: list, should_be_removed: bool, ): - """Test the logic of the remove_completed job with various scenarios.""" + """Test the logic of the remove_done_seeding job with various scenarios.""" item = {**ITEM_DEFAULTS, **item_properties, "name": "test_item"} settings = create_mock_settings(target_tags, target_categories) client = create_mock_download_client([item]) - job = RemoveCompleted(client, "qbittorrent", settings, "remove_completed") + job = RemoveDoneSeeding(client, "qbittorrent", settings, "remove_done_seeding") items_to_remove = await job._get_items_to_remove(await client.get_qbit_items()) @@ -143,11 +143,11 @@ async def test_remove_completed_logic( @pytest.mark.asyncio -async def test_remove_completed_skipped_for_sabnzbd(): - """Test that the remove_completed job is skipped for SABnzbd clients.""" +async def test_remove_done_seeding_skipped_for_sabnzbd(): + """Test that the remove_done_seeding job is skipped for SABnzbd clients.""" settings = create_mock_settings() client = create_mock_download_client([], client_name="mock_client_name") - job = RemoveCompleted(client, "sabnzbd", settings, "remove_completed") + job = RemoveDoneSeeding(client, "sabnzbd", settings, "remove_done_seeding") # Test that the job returns 0 for unsupported clients result = await job.run() @@ -158,7 +158,7 @@ async def test_remove_completed_skipped_for_sabnzbd(): @pytest.mark.asyncio -async def test_remove_completed_test_run_enabled(): +async def test_remove_done_seeding_test_run_enabled(): """Test that no items are actually removed when test_run is enabled.""" item = { **ITEM_DEFAULTS, @@ -170,7 +170,7 @@ async def test_remove_completed_test_run_enabled(): settings = create_mock_settings(target_tags=["tag1"]) settings.general.test_run = True client = create_mock_download_client([item]) - job = RemoveCompleted(client, "qbittorrent", settings, "remove_completed") + job = RemoveDoneSeeding(client, "qbittorrent", settings, "remove_done_seeding") with patch.object( client, @@ -188,7 +188,7 @@ async def test_remove_completed_test_run_enabled(): @pytest.mark.asyncio @pytest.mark.parametrize("protected_on", ["tag", "category"]) -async def test_remove_completed_with_protected_item(protected_on): +async def test_remove_done_seeding_with_protected_item(protected_on): """Test that items with a protected tag or category are not removed.""" item_properties = {"ratio": 2, "ratio_limit": 2, "name": "protected_item"} target_tags = ["tag1"] @@ -209,7 +209,7 @@ async def test_remove_completed_with_protected_item(protected_on): target_categories=target_categories, ) client = create_mock_download_client([item]) - job = RemoveCompleted(client, "qbittorrent", settings, "remove_completed") + job = RemoveDoneSeeding(client, "qbittorrent", settings, "remove_done_seeding") with patch.object( job, @@ -224,7 +224,9 @@ async def test_remove_completed_with_protected_item(protected_on): @pytest.mark.asyncio async def test_is_completed_logic(): """Test the internal _is_completed logic with different states and limits.""" - job = RemoveCompleted(MagicMock(), "qbittorrent", MagicMock(), "remove_completed") + job = RemoveDoneSeeding( + MagicMock(), "qbittorrent", MagicMock(), "remove_done_seeding" + ) # Completed states for state in COMPLETED_STATES: From 2fb373a54861cb740544c0bb2d6b9e10454aca79 Mon Sep 17 00:00:00 2001 From: Benjamin Harder Date: Sun, 19 Oct 2025 18:49:07 +0200 Subject: [PATCH 21/22] pylint fix --- src/settings/_general.py | 1 - tests/jobs/test_remove_completed.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/settings/_general.py b/src/settings/_general.py index 2c0801d..ccd42a6 100644 --- a/src/settings/_general.py +++ b/src/settings/_general.py @@ -72,4 +72,3 @@ def config_as_yaml(self): vars(self), ) return config_yaml - diff --git a/tests/jobs/test_remove_completed.py b/tests/jobs/test_remove_completed.py index 4e6db2b..b7a2681 100644 --- a/tests/jobs/test_remove_completed.py +++ b/tests/jobs/test_remove_completed.py @@ -1,3 +1,4 @@ + # pylint: disable=W0212 """Tests for the remove_done_seeding job.""" from unittest.mock import AsyncMock, MagicMock, patch From 057cbdc1c1497c10d58b8787f7e08d9575b05819 Mon Sep 17 00:00:00 2001 From: Benjamin Harder Date: Sat, 1 Nov 2025 16:31:07 +0100 Subject: [PATCH 22/22] Finalized readme --- README.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 546665e..28923a7 100644 --- a/README.md +++ b/README.md @@ -504,9 +504,8 @@ This is the interesting section. It defines which job you want decluttarr to run #### 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 the seeding goals configured in your download client are met: - - Either the ratio limit is reached - - Or the seeding time limit (per-torrent overrides or the global limits). +- "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 @@ -515,16 +514,16 @@ This is the interesting section. It defines which job you want decluttarr to run - `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. + - 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. + - 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. + - 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