From 005fd1b055290e92465bba01d5feb45cfc4d9362 Mon Sep 17 00:00:00 2001 From: Rodrigo Braz <rfsbraz@gmail.com> Date: Mon, 8 Apr 2024 22:46:09 +0100 Subject: [PATCH 1/2] feat(radarr): Add support to exclude movies on deletion --- app/config.py | 28 +++++++++++++++++++++++++++- app/constants.py | 8 ++++++++ app/media_cleaner.py | 12 ++++++++++-- tests/test_media_cleaner.py | 4 ++-- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/app/config.py b/app/config.py index af1e40e..7dcdb65 100644 --- a/app/config.py +++ b/app/config.py @@ -7,7 +7,13 @@ import yaml from app import logger -from app.constants import VALID_ACTION_MODES, VALID_SORT_FIELDS, VALID_SORT_ORDERS +from app.constants import ( + SETTINGS_PER_ACTION, + SETTINGS_PER_INSTANCE, + VALID_ACTION_MODES, + VALID_SORT_FIELDS, + VALID_SORT_ORDERS, +) from app.modules.tautulli import Tautulli from app.modules.trakt import Trakt from app.utils import validate_units @@ -72,6 +78,17 @@ def validate_trakt(self): logger.debug(f"Error: {err}") return False + def validate_settings_for_instance(self, library): + instance_type = "radarr" if "radarr" in library else "sonarr" + for setting in library: + if ( + setting in SETTINGS_PER_INSTANCE + and instance_type in SETTINGS_PER_INSTANCE[setting] + ): + self.log_and_exit( + f"'{setting}' can only be set for {instance_type} instances" + ) + def validate_sonarr_and_radarr(self): sonarr_settings = self.settings.get("sonarr", []) radarr_settings = self.settings.get("radarr", []) @@ -183,6 +200,15 @@ def validate_action_mode(self, library): f"Invalid action_mode '{library['action_mode']}' in library '{library['name']}', it should be either 'delete'." ) + # Validate settings per action + for setting in library: + if setting in SETTINGS_PER_ACTION and library[ + "action_mode" + ] not in SETTINGS_PER_ACTION.get(setting, []): + self.log_and_exit( + f"'{setting}' can only be set when action_mode is '{library['action_mode']}' for library '{library['name']}'." + ) + def validate_watch_status(self, library): if "watch_status" in library and library["watch_status"] not in [ "watched", diff --git a/app/constants.py b/app/constants.py index 67313ca..b4c084d 100644 --- a/app/constants.py +++ b/app/constants.py @@ -15,3 +15,11 @@ # Valid action modes VALID_ACTION_MODES = ["delete"] + +SETTINGS_PER_ACTION = { + "exclude_on_delete": ["delete"], +} + +SETTINGS_PER_INSTANCE = { + "exclude_on_delete": ["radarr"], +} diff --git a/app/media_cleaner.py b/app/media_cleaner.py index 678be8f..147e56e 100644 --- a/app/media_cleaner.py +++ b/app/media_cleaner.py @@ -347,9 +347,17 @@ def delete_movie_if_allowed( library.get("name"), ) if input().lower() == "y": - radarr_instance.del_movie(radarr_movie["id"], delete_files=True) + radarr_instance.del_movie( + radarr_movie["id"], + delete_files=True, + add_exclusion=library.get("exclude_on_delete", False), + ) else: - radarr_instance.del_movie(radarr_movie["id"], delete_files=True) + radarr_instance.del_movie( + radarr_movie["id"], + delete_files=True, + add_exclusion=library.get("exclude_on_delete", False), + ) def get_library_config(self, config, show): return next( diff --git a/tests/test_media_cleaner.py b/tests/test_media_cleaner.py index 98beba7..0496bb0 100644 --- a/tests/test_media_cleaner.py +++ b/tests/test_media_cleaner.py @@ -1064,7 +1064,7 @@ def test_delete_movie_if_allowed_interactive_yes(mock_input, standard_config): # Assert mock_input.assert_called_once() radarr_instance.del_movie.assert_called_once_with( - radarr_movie["id"], delete_files=True + radarr_movie["id"], delete_files=True, add_exclusion=False ) @@ -1120,7 +1120,7 @@ def test_delete_movie_if_allowed_not_interactive(standard_config): # Assert radarr_instance.del_movie.assert_called_once_with( - radarr_movie["id"], delete_files=True + radarr_movie["id"], delete_files=True, add_exclusion=False ) From 3673c3a284235b506cdd22e871e5b224075fa500 Mon Sep 17 00:00:00 2001 From: Rodrigo Braz <rfsbraz@gmail.com> Date: Wed, 17 Apr 2024 11:46:11 +0100 Subject: [PATCH 2/2] chore(radarr): Add list exclusion test coverage --- app/config.py | 5 ++-- app/constants.py | 6 +++-- app/media_cleaner.py | 4 +-- config/settings.yaml.example | 1 + docs/CONFIGURATION.md | 3 ++- tests/test_config.py | 47 +++++++++++++++++++++++++++++++++++- 6 files changed, 58 insertions(+), 8 deletions(-) diff --git a/app/config.py b/app/config.py index 7dcdb65..22ec9d5 100644 --- a/app/config.py +++ b/app/config.py @@ -83,10 +83,10 @@ def validate_settings_for_instance(self, library): for setting in library: if ( setting in SETTINGS_PER_INSTANCE - and instance_type in SETTINGS_PER_INSTANCE[setting] + and instance_type not in SETTINGS_PER_INSTANCE[setting] ): self.log_and_exit( - f"'{setting}' can only be set for {instance_type} instances" + f"'{setting}' can only be set for instances of type: {SETTINGS_PER_INSTANCE[setting]}" ) def validate_sonarr_and_radarr(self): @@ -157,6 +157,7 @@ def validate_libraries(self): self.validate_action_mode(library) self.validate_watch_status(library) self.validate_sort_configuration(library) + self.validate_settings_for_instance(library) return True diff --git a/app/constants.py b/app/constants.py index b4c084d..18375ac 100644 --- a/app/constants.py +++ b/app/constants.py @@ -10,6 +10,8 @@ "episodes", ] +VALID_INSTANCE_TYPES = ["radarr", "sonarr"] + # Valid sort orders VALID_SORT_ORDERS = ["asc", "desc"] @@ -17,9 +19,9 @@ VALID_ACTION_MODES = ["delete"] SETTINGS_PER_ACTION = { - "exclude_on_delete": ["delete"], + "add_list_exclusion_on_delete": ["delete"], } SETTINGS_PER_INSTANCE = { - "exclude_on_delete": ["radarr"], + "add_list_exclusion_on_delete": ["radarr"], } diff --git a/app/media_cleaner.py b/app/media_cleaner.py index 147e56e..e44cbf5 100644 --- a/app/media_cleaner.py +++ b/app/media_cleaner.py @@ -350,13 +350,13 @@ def delete_movie_if_allowed( radarr_instance.del_movie( radarr_movie["id"], delete_files=True, - add_exclusion=library.get("exclude_on_delete", False), + add_exclusion=library.get("add_list_exclusion_on_delete", False), ) else: radarr_instance.del_movie( radarr_movie["id"], delete_files=True, - add_exclusion=library.get("exclude_on_delete", False), + add_exclusion=library.get("add_list_exclusion_on_delete", False), ) def get_library_config(self, config, show): diff --git a/config/settings.yaml.example b/config/settings.yaml.example index 9e913d1..df41444 100644 --- a/config/settings.yaml.example +++ b/config/settings.yaml.example @@ -51,6 +51,7 @@ libraries: - name: "Movies" # The name of your Plex library radarr: "Radarr" # The Radarr instance to use for this library action_mode: "delete" # Actions can be "delete" + add_list_exclusion_on_delete: True # Prevents radarr from importing the deleted movie automatically again from a list last_watched_threshold: 30 # Time threshold in days. Media not watched in this period will be subject to actions watch_status: watched # Watched status of the media apply_last_watch_threshold_to_collections: true # If true, the last watched threshold will be applied to all other items in the collection diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 819eea1..2050242 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -148,8 +148,9 @@ For each of your Plex libraries, specify how you want Deleterr to behave. Define | `series_type` | Only used if `sonarr` is set. It's required to filter for the show type, defaults to `standard`. | `"standard", "anime"` | `standard`, `anime`, `daily` | | `action_mode` | The action to perform on the media items. | `delete` | `delete` | | `last_watched_threshold` | Time threshold in days. Media watched in this period will not be actionable | `90` | - | +| `add_list_exclusion_on_delete` | Prevent Radarr/Sonarr from importing the media automatically again from a list. Currently only works with Radarr. | `true` | `true`,`false` | | `watch_status` | Watch status. Media not in this is state will not be actionable | `-` | `watched`, `unwatched` | -| `apply_last_watch_threshold_to_collections` | If set to `true`, the last watched threshold will be applied to all other items in the same collection. | `true` | `true` / `false` | +| `apply_last_watch_threshold_to_collections` | If set to `true`, the last watched threshold will be applied to all other items in the same collection. | `true` | `true`, `false` | | `added_at_threshold` | Media that added to Plex within this period (in days) will not be actionable | `180` | - | | `disk_size_threshold` | Library deletion will only happen when below this threshold. It requires a `path` (that the `sonarr` or `radarr` instance can access) and a size threshold | `path: /media/local` </br> `threshold: 1TB` | Valid units: [`B`, `KB`, `MB`, `GB`, `TB`, `PB`, `EB`] | | `max_actions_per_run` | Limit the number of actions performed per run. Defaults to `10` | `3000` | - | diff --git a/tests/test_config.py b/tests/test_config.py index c7ec8f2..5b5161b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,14 @@ import pytest from app.config import Config -from app.constants import VALID_ACTION_MODES, VALID_SORT_FIELDS, VALID_SORT_ORDERS +from app.constants import ( + SETTINGS_PER_ACTION, + SETTINGS_PER_INSTANCE, + VALID_ACTION_MODES, + VALID_INSTANCE_TYPES, + VALID_SORT_FIELDS, + VALID_SORT_ORDERS, +) # Test case for validate_libraries @@ -66,6 +73,44 @@ def test_invalid_sorting_options(library_config): validator.validate_libraries() +@pytest.mark.parametrize("action_mode", VALID_ACTION_MODES) +@pytest.mark.parametrize("setting", SETTINGS_PER_ACTION.keys()) +@pytest.mark.parametrize("instance", VALID_INSTANCE_TYPES) +def test_settings_per_instance_and_action_mode(action_mode, setting, instance): + library_config = { + "name": "TV Shows", + "action_mode": action_mode, + instance: "test", + setting: True, + } + + instance_config = [ + {"name": "test", "url": "http://localhost:8989", "api_key": "API_KEY"} + ] + + validator = Config({"libraries": [library_config], instance: instance_config}) + + if ( + # If the setting is valid for the action mode or the action mode is not specified + (setting in SETTINGS_PER_ACTION and action_mode in SETTINGS_PER_ACTION[setting]) + or (setting not in SETTINGS_PER_ACTION) + ) and ( + ( + # And if the setting is valid for the instance + setting in SETTINGS_PER_INSTANCE + and instance in SETTINGS_PER_INSTANCE[setting] + ) + # Or the instance is not specified + or (setting not in SETTINGS_PER_INSTANCE) + ): + # Then the validation should pass + assert validator.validate_libraries() == True + else: + # Otherwise, the validation should fail + with pytest.raises(SystemExit): + validator.validate_libraries() + + # Test case for validate_libraries @pytest.mark.parametrize("sort_field", VALID_SORT_FIELDS) @pytest.mark.parametrize("sort_order", VALID_SORT_ORDERS)