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)