From bcfd4a55840adcd6298e5a9cee454d8e4e27f798 Mon Sep 17 00:00:00 2001 From: Rodrigo Braz Date: Sun, 18 Feb 2024 08:38:27 +0000 Subject: [PATCH 01/12] feat: Add support for disk size filter (#67) --- app/deleterr.py | 36 ++++++++++++++++++++++++++++++++++-- app/utils.py | 15 ++++++++++++++- tests/test_utils.py | 14 +++++++++++++- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/app/deleterr.py b/app/deleterr.py index 54c2dac..5c0e19e 100644 --- a/app/deleterr.py +++ b/app/deleterr.py @@ -14,7 +14,7 @@ from app import logger from plexapi.server import PlexServer from plexapi.exceptions import NotFound -from app.utils import print_readable_freed_space +from app.utils import print_readable_freed_space, parse_size_to_bytes from app.config import load_config from pyarr.exceptions import PyarrResourceNotFound, PyarrServerError @@ -63,6 +63,32 @@ def __init__(self, config): self.process_sonarr() self.process_radarr() + def library_meets_disk_space_threshold(self, library, pyarr): + for item in library.get("disk_size_threshold"): + path = item.get("path") + threshold = item.get("threshold") + disk_space = pyarr.get_disk_space() + folder_found = False + for folder in disk_space: + if folder["path"] == path: + folder_found = True + free_space = folder["freeSpace"] + logger.debug( + f"Free space for '{path}': {print_readable_freed_space(free_space)} (threshold: {threshold})" + ) + if free_space > parse_size_to_bytes(threshold): + logger.info( + f"Skipping library '{library.get('name')}' as free space is above threshold ({print_readable_freed_space(free_space)} > {threshold})" + ) + return False + if not folder_found: + logger.error( + f"Could not find folder '{path}' in server instance. Skipping library '{library.get('name')}'" + ) + return False + + return True + def delete_series(self, sonarr, sonarr_show): ## PyArr doesn't support deleting the series files, so we need to do it manually episodes = sonarr.get_episode(sonarr_show["id"], series=True) @@ -106,6 +132,9 @@ def process_sonarr(self): saved_space = 0 for library in self.config.settings.get("libraries", []): if library.get("sonarr") == name: + if not self.library_meets_disk_space_threshold(library, sonarr): + continue + all_show_data = [ show for show in unfiltered_all_show_data @@ -206,6 +235,9 @@ def process_radarr(self): saved_space = 0 for library in self.config.settings.get("libraries", []): if library.get("radarr") == name: + if not self.library_meets_disk_space_threshold(library, radarr): + continue + max_actions_per_run = _get_config_value( library, "max_actions_per_run", DEFAULT_MAX_ACTIONS_PER_RUN ) @@ -703,7 +735,7 @@ def main(): logger.info("Running version %s", get_file_contents("/app/commit_tag.txt")) logger.info("Log level set to %s", log_level) - config = load_config("/config/settings.yaml") + config = load_config("config/settings.yaml") config.validate() Deleterr(config) diff --git a/app/utils.py b/app/utils.py index ee2ede1..3778bdc 100644 --- a/app/utils.py +++ b/app/utils.py @@ -6,4 +6,17 @@ def print_readable_freed_space(saved_space): saved_space /= 1024 index += 1 - return f"{saved_space:.2f} {units[index]}" \ No newline at end of file + return f"{saved_space:.2f} {units[index]}" + +def parse_size_to_bytes(size_str): + units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] + unit = ''.join([i for i in size_str if not i.isdigit() and not i == '.']) + size = ''.join([i for i in size_str if i.isdigit() or i == '.']) + size = float(size) + index = units.index(unit) + + while index > 0: + size *= 1024 + index -= 1 + + return int(size) \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py index e99ba0c..bcb4ee3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,5 @@ import pytest -from app.utils import print_readable_freed_space +from app.utils import print_readable_freed_space, parse_size_to_bytes @pytest.mark.parametrize("input_size, expected_output", [ (500, "500.00 Bytes"), @@ -12,3 +12,15 @@ def test_print_readable_freed_space(input_size, expected_output): result = print_readable_freed_space(input_size) assert result == expected_output, f"For {input_size}, expected {expected_output} but got {result}" + +@pytest.mark.parametrize("input_size, expected_output", [ + ('2TB', 2199023255552), + ('5.6GB', 6012954214), + ("1GB", 1073741824), + ('230MB', 241172480), + ('1B', 1), + ('0KB', 0), +]) +def test_print_readable_freed_space(input_size, expected_output): + result = parse_size_to_bytes(input_size) + assert result == expected_output, f"For {input_size}, expected {expected_output} but got {result}" \ No newline at end of file From c1c32f1f170bc6035f3cff195eab6dd7ddb7fdc0 Mon Sep 17 00:00:00 2001 From: Rodrigo Braz Date: Mon, 19 Feb 2024 12:18:33 +0000 Subject: [PATCH 02/12] chore: Also upload PR images to registries --- .github/workflows/docker-image-dockerhub.yml | 2 +- .github/workflows/docker-image-ghcr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-image-dockerhub.yml b/.github/workflows/docker-image-dockerhub.yml index d5dfd13..57d9e2c 100644 --- a/.github/workflows/docker-image-dockerhub.yml +++ b/.github/workflows/docker-image-dockerhub.yml @@ -78,7 +78,7 @@ jobs: uses: docker/build-push-action@v3 with: context: . - push: ${{ github.event_name != 'pull_request' }} + push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | diff --git a/.github/workflows/docker-image-ghcr.yml b/.github/workflows/docker-image-ghcr.yml index a721b74..e134789 100644 --- a/.github/workflows/docker-image-ghcr.yml +++ b/.github/workflows/docker-image-ghcr.yml @@ -80,7 +80,7 @@ jobs: uses: docker/build-push-action@v3 with: context: . - push: ${{ github.event_name != 'pull_request' }} + push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | From 425f0c077a5dc2c9b46725b0cb347a706badd9be Mon Sep 17 00:00:00 2001 From: Rodrigo Braz Date: Mon, 19 Feb 2024 15:32:49 +0000 Subject: [PATCH 03/12] chore: Add disk threshold test coverage --- .github/workflows/docker-image-dockerhub.yml | 2 +- .github/workflows/docker-image-ghcr.yml | 2 +- app/deleterr.py | 57 +++++++-------- tests/test_deleterr.py | 75 ++++++++++++++++++++ 4 files changed, 106 insertions(+), 30 deletions(-) create mode 100644 tests/test_deleterr.py diff --git a/.github/workflows/docker-image-dockerhub.yml b/.github/workflows/docker-image-dockerhub.yml index 57d9e2c..e38afd5 100644 --- a/.github/workflows/docker-image-dockerhub.yml +++ b/.github/workflows/docker-image-dockerhub.yml @@ -1,4 +1,4 @@ -name: Create and publish a Docker image +name: Create and publish a Docker image to dockerhub on: schedule: diff --git a/.github/workflows/docker-image-ghcr.yml b/.github/workflows/docker-image-ghcr.yml index e134789..c884a04 100644 --- a/.github/workflows/docker-image-ghcr.yml +++ b/.github/workflows/docker-image-ghcr.yml @@ -1,4 +1,4 @@ -name: Create and publish a Docker image +name: Create and publish a Docker image to GitHub Packages on: schedule: diff --git a/app/deleterr.py b/app/deleterr.py index 5c0e19e..8c068cd 100644 --- a/app/deleterr.py +++ b/app/deleterr.py @@ -63,32 +63,6 @@ def __init__(self, config): self.process_sonarr() self.process_radarr() - def library_meets_disk_space_threshold(self, library, pyarr): - for item in library.get("disk_size_threshold"): - path = item.get("path") - threshold = item.get("threshold") - disk_space = pyarr.get_disk_space() - folder_found = False - for folder in disk_space: - if folder["path"] == path: - folder_found = True - free_space = folder["freeSpace"] - logger.debug( - f"Free space for '{path}': {print_readable_freed_space(free_space)} (threshold: {threshold})" - ) - if free_space > parse_size_to_bytes(threshold): - logger.info( - f"Skipping library '{library.get('name')}' as free space is above threshold ({print_readable_freed_space(free_space)} > {threshold})" - ) - return False - if not folder_found: - logger.error( - f"Could not find folder '{path}' in server instance. Skipping library '{library.get('name')}'" - ) - return False - - return True - def delete_series(self, sonarr, sonarr_show): ## PyArr doesn't support deleting the series files, so we need to do it manually episodes = sonarr.get_episode(sonarr_show["id"], series=True) @@ -132,7 +106,7 @@ def process_sonarr(self): saved_space = 0 for library in self.config.settings.get("libraries", []): if library.get("sonarr") == name: - if not self.library_meets_disk_space_threshold(library, sonarr): + if not library_meets_disk_space_threshold(library, sonarr): continue all_show_data = [ @@ -235,7 +209,7 @@ def process_radarr(self): saved_space = 0 for library in self.config.settings.get("libraries", []): if library.get("radarr") == name: - if not self.library_meets_disk_space_threshold(library, radarr): + if not library_meets_disk_space_threshold(library, radarr): continue max_actions_per_run = _get_config_value( @@ -719,6 +693,33 @@ def get_file_contents(file_path): print(f"Error reading file {file_path}: {e}") +def library_meets_disk_space_threshold(library, pyarr): + for item in library.get("disk_size_threshold"): + path = item.get("path") + threshold = item.get("threshold") + disk_space = pyarr.get_disk_space() + folder_found = False + for folder in disk_space: + if folder["path"] == path: + folder_found = True + free_space = folder["freeSpace"] + logger.debug( + f"Free space for '{path}': {print_readable_freed_space(free_space)} (threshold: {threshold})" + ) + if free_space > parse_size_to_bytes(threshold): + logger.info( + f"Skipping library '{library.get('name')}' as free space is above threshold ({print_readable_freed_space(free_space)} > {threshold})" + ) + return False + if not folder_found: + logger.error( + f"Could not find folder '{path}' in server instance. Skipping library '{library.get('name')}'" + ) + return False + + return True + + def main(): """ Deleterr application entry point. Parses arguments, configs and diff --git a/tests/test_deleterr.py b/tests/test_deleterr.py new file mode 100644 index 0000000..50dfe4c --- /dev/null +++ b/tests/test_deleterr.py @@ -0,0 +1,75 @@ +import unittest +from unittest.mock import Mock +from app.deleterr import library_meets_disk_space_threshold, find_watched_data + + +class TestLibraryMeetsDiskSpaceThreshold(unittest.TestCase): + def setUp(self): + self.pyarr = Mock() + self.library = { + "disk_size_threshold": [ + {"path": "/data/media/local", "threshold": "1TB"} + ], + "name": "Test Library" + } + + def test_meets_threshold(self): + self.pyarr.get_disk_space.return_value = [ + {"path": "/data/media/local", "freeSpace": 500000000000 } # 500GB + ] + self.assertTrue(library_meets_disk_space_threshold(self.library, self.pyarr)) + + def test_does_not_meet_threshold(self): + self.pyarr.get_disk_space.return_value = [ + {"path": "/data/media/local", "freeSpace": 2000000000000 } # 2TB + ] + self.assertFalse(library_meets_disk_space_threshold(self.library, self.pyarr)) + + def test_folder_not_found(self): + self.pyarr.get_disk_space.return_value = [ + {"path": "/data/media/other", "freeSpace": 500000000000} + ] + self.assertFalse(library_meets_disk_space_threshold(self.library, self.pyarr)) + +class TestFindWatchedData(unittest.TestCase): + def setUp(self): + self.activity_data = { + "guid1": {"title": "Title1", "year": 2000}, + "guid2": {"title": "Title2", "year": 2001}, + "guid3": {"title": "Title3", "year": 2002}, + } + + def test_guid_in_activity_data(self): + plex_media_item = Mock() + plex_media_item.guid = "guid1" + plex_media_item.title = "Title1" + plex_media_item.year = 2000 + self.assertEqual(find_watched_data(plex_media_item, self.activity_data), self.activity_data["guid1"]) + + def test_guid_in_guid(self): + plex_media_item = Mock() + plex_media_item.guid = "guid1" + plex_media_item.title = "Title4" + plex_media_item.year = 2003 + self.assertEqual(find_watched_data(plex_media_item, self.activity_data), self.activity_data["guid1"]) + + def test_title_match(self): + plex_media_item = Mock() + plex_media_item.guid = "guid2" + plex_media_item.title = "Title2" + plex_media_item.year = 2001 + self.assertEqual(find_watched_data(plex_media_item, self.activity_data), self.activity_data["guid2"]) + + def test_year_difference_less_than_one(self): + plex_media_item = Mock() + plex_media_item.guid = "guid4" + plex_media_item.title = "Title3" + plex_media_item.year = 2003 + self.assertEqual(find_watched_data(plex_media_item, self.activity_data), self.activity_data["guid3"]) + + def test_no_match(self): + plex_media_item = Mock() + plex_media_item.guid = "guid4" + plex_media_item.title = "Title4" + plex_media_item.year = 2004 + self.assertIsNone(find_watched_data(plex_media_item, self.activity_data)) \ No newline at end of file From cf481f29d225f172a993b286a2cba8a11c72cc3b Mon Sep 17 00:00:00 2001 From: Rodrigo Braz Date: Mon, 19 Feb 2024 16:04:08 +0000 Subject: [PATCH 04/12] chore: Add PR comment with image run instructions --- .github/workflows/comment-pr.yml | 30 ++++++++++++++++++++ .github/workflows/docker-image-dockerhub.yml | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/comment-pr.yml diff --git a/.github/workflows/comment-pr.yml b/.github/workflows/comment-pr.yml new file mode 100644 index 0000000..c32efee --- /dev/null +++ b/.github/workflows/comment-pr.yml @@ -0,0 +1,30 @@ +name: Update PR with docker image + +on: + workflow_run: + workflows: ["Create and publish a Docker image to Dockerhub", "Create and publish a Docker image to GitHub Packages"] + types: + - completed + pull_request: + types: [opened, reopened] + +jobs: + comment-pr: + runs-on: ubuntu-latest + name: Upsert comment on the PR + steps: + - uses: thollander/actions-comment-pull-request@v2 + with: + message: | + :robot: A Docker image for this PR is available to test with: + + ## GitHub Packages + ```bash + docker run --rm -v ./config:/config -v ./logs:/config/logs ghcr.io/rfsbraz/deleterr:pr-${{ github.event.pull_request.number }} -e LOG_LEVEL=DEBUG + ``` + + ## Dockerhub + ```bash + docker run --rm -v ./config:/config -v ./logs:/config/logs rfsbraz/deleterr:pr-${{ github.event.pull_request.number }} -e LOG_LEVEL=DEBUG + ``` + comment_tag: docker_image_instructions \ No newline at end of file diff --git a/.github/workflows/docker-image-dockerhub.yml b/.github/workflows/docker-image-dockerhub.yml index e38afd5..16a223e 100644 --- a/.github/workflows/docker-image-dockerhub.yml +++ b/.github/workflows/docker-image-dockerhub.yml @@ -1,4 +1,4 @@ -name: Create and publish a Docker image to dockerhub +name: Create and publish a Docker image to Dockerhub on: schedule: From 0cbd4b11ce5a658d42e931a90291bb003f302fa5 Mon Sep 17 00:00:00 2001 From: Rodrigo Braz Date: Mon, 19 Feb 2024 16:09:55 +0000 Subject: [PATCH 05/12] chore: Comment on synchronize as well --- .github/workflows/comment-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/comment-pr.yml b/.github/workflows/comment-pr.yml index c32efee..dcd72dd 100644 --- a/.github/workflows/comment-pr.yml +++ b/.github/workflows/comment-pr.yml @@ -6,7 +6,7 @@ on: types: - completed pull_request: - types: [opened, reopened] + types: [opened, reopened, synchronize] jobs: comment-pr: From a1993a2fa42751603c52862ce2cfdd0aef0313ef Mon Sep 17 00:00:00 2001 From: Rodrigo Braz Date: Mon, 19 Feb 2024 16:12:10 +0000 Subject: [PATCH 06/12] chore: Add missing permission --- .github/workflows/comment-pr.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/comment-pr.yml b/.github/workflows/comment-pr.yml index dcd72dd..d78d721 100644 --- a/.github/workflows/comment-pr.yml +++ b/.github/workflows/comment-pr.yml @@ -1,5 +1,8 @@ name: Update PR with docker image +permissions: + pull-requests: write + on: workflow_run: workflows: ["Create and publish a Docker image to Dockerhub", "Create and publish a Docker image to GitHub Packages"] From e63997e35947b0a13a8a3b9cb3b66744085ce647 Mon Sep 17 00:00:00 2001 From: Rodrigo Braz Date: Mon, 19 Feb 2024 16:22:20 +0000 Subject: [PATCH 07/12] chore: Improve github robot comment --- .github/workflows/comment-pr.yml | 12 ++++-------- .github/workflows/docker-image-dockerhub.yml | 4 ---- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/comment-pr.yml b/.github/workflows/comment-pr.yml index d78d721..89ada84 100644 --- a/.github/workflows/comment-pr.yml +++ b/.github/workflows/comment-pr.yml @@ -2,10 +2,10 @@ name: Update PR with docker image permissions: pull-requests: write - + on: workflow_run: - workflows: ["Create and publish a Docker image to Dockerhub", "Create and publish a Docker image to GitHub Packages"] + workflows: ["Create and publish a Docker image to GitHub Packages"] types: - completed pull_request: @@ -21,13 +21,9 @@ jobs: message: | :robot: A Docker image for this PR is available to test with: - ## GitHub Packages ```bash - docker run --rm -v ./config:/config -v ./logs:/config/logs ghcr.io/rfsbraz/deleterr:pr-${{ github.event.pull_request.number }} -e LOG_LEVEL=DEBUG + docker run -e LOG_LEVEL=DEBUG --rm -v ./config:/config -v ./logs:/config/logs ghcr.io/rfsbraz/deleterr:pr-${{ github.event.pull_request.number }} ``` - ## Dockerhub - ```bash - docker run --rm -v ./config:/config -v ./logs:/config/logs rfsbraz/deleterr:pr-${{ github.event.pull_request.number }} -e LOG_LEVEL=DEBUG - ``` + This assumes you have a `config` and `logs` directory where you're running the command. You can adjust the volume mounts as needed. comment_tag: docker_image_instructions \ No newline at end of file diff --git a/.github/workflows/docker-image-dockerhub.yml b/.github/workflows/docker-image-dockerhub.yml index 16a223e..aaba7ee 100644 --- a/.github/workflows/docker-image-dockerhub.yml +++ b/.github/workflows/docker-image-dockerhub.yml @@ -9,10 +9,6 @@ on: - 'develop' tags: - 'v*' - pull_request: - branches: - - 'master' - - 'develop' # Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. env: From 4b4bc0442827883dc8b732389f4d9f1f0b7d7ded Mon Sep 17 00:00:00 2001 From: Rodrigo Braz Date: Mon, 19 Feb 2024 16:29:21 +0000 Subject: [PATCH 08/12] chore: Fix trailing whitespaces and duplicate function name --- app/deleterr.py | 1 - tests/test_utils.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/deleterr.py b/app/deleterr.py index 8c068cd..6c11b53 100644 --- a/app/deleterr.py +++ b/app/deleterr.py @@ -716,7 +716,6 @@ def library_meets_disk_space_threshold(library, pyarr): f"Could not find folder '{path}' in server instance. Skipping library '{library.get('name')}'" ) return False - return True diff --git a/tests/test_utils.py b/tests/test_utils.py index bcb4ee3..4e17a9f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -17,10 +17,10 @@ def test_print_readable_freed_space(input_size, expected_output): ('2TB', 2199023255552), ('5.6GB', 6012954214), ("1GB", 1073741824), - ('230MB', 241172480), + ('230MB', 241172480), ('1B', 1), ('0KB', 0), ]) -def test_print_readable_freed_space(input_size, expected_output): +def test_parse_size_to_bytes(input_size, expected_output): result = parse_size_to_bytes(input_size) assert result == expected_output, f"For {input_size}, expected {expected_output} but got {result}" \ No newline at end of file From 49f134bccf227dd4d20d29cc61221522cba59e5b Mon Sep 17 00:00:00 2001 From: Rodrigo Braz Date: Mon, 19 Feb 2024 16:35:27 +0000 Subject: [PATCH 09/12] chore: format code --- app/config.py | 59 +++++++++++++++-------- app/constants.py | 15 ++++-- app/logger.py | 21 ++++++-- app/modules/tautulli.py | 2 +- app/modules/trakt.py | 2 +- app/utils.py | 15 +++--- tests/test_config.py | 100 +++++++++++++++++++-------------------- tests/test_deleterr.py | 33 ++++++++----- tests/test_exclusion.py | 0 tests/test_sort_media.py | 95 +++++++++++++++++++++++++++---------- tests/test_tautulli.py | 2 +- tests/test_utils.py | 48 ++++++++++++------- 12 files changed, 250 insertions(+), 142 deletions(-) create mode 100644 tests/test_exclusion.py diff --git a/app/config.py b/app/config.py index 75fba35..c3804bc 100644 --- a/app/config.py +++ b/app/config.py @@ -9,6 +9,7 @@ from app.modules.trakt import Trakt from app.constants import VALID_SORT_FIELDS, VALID_SORT_ORDERS, VALID_ACTION_MODES + def load_config(config_file): try: full_path = os.path.abspath(config_file) @@ -23,7 +24,8 @@ def load_config(config_file): logger.error(exc) sys.exit(1) - + + class Config: def __init__(self, config_file): self.settings = config_file @@ -56,7 +58,10 @@ def validate_trakt(self): if not self.settings.get("trakt"): return True try: - t = Trakt(self.settings.get("trakt", {}).get("client_id"), self.settings.get("trakt", {}).get("client_secret")) + t = Trakt( + self.settings.get("trakt", {}).get("client_id"), + self.settings.get("trakt", {}).get("client_secret"), + ) t.test_connection() return True except Exception as err: @@ -69,8 +74,12 @@ def validate_sonarr_and_radarr(self): radarr_settings = self.settings.get("radarr", []) # Check if sonarr_settings and radarr_settings are lists - if not isinstance(sonarr_settings, list) or not isinstance(radarr_settings, list): - self.log_and_exit("sonarr and radarr settings should be a list of dictionaries.") + if not isinstance(sonarr_settings, list) or not isinstance( + radarr_settings, list + ): + self.log_and_exit( + "sonarr and radarr settings should be a list of dictionaries." + ) return all( self.test_api_connection(connection) @@ -109,7 +118,7 @@ def validate_tautulli(self): ) logger.debug(f"Error: {err}") return False - + return True def validate_libraries(self): @@ -118,12 +127,16 @@ def validate_libraries(self): libraries = self.settings.get("libraries", []) if not libraries: - self.log_and_exit("No libraries configured. Please check your configuration.") - + self.log_and_exit( + "No libraries configured. Please check your configuration." + ) + for library in libraries: - if 'sonarr' not in library and 'radarr' not in library: - self.log_and_exit(f"Neither 'sonarr' nor 'radarr' is configured for library '{library.get('name')}'. Please check your configuration.") - + if "sonarr" not in library and "radarr" not in library: + self.log_and_exit( + f"Neither 'sonarr' nor 'radarr' is configured for library '{library.get('name')}'. Please check your configuration." + ) + if ( len(library.get("exclude", {}).get("trakt_lists", [])) > 0 and not trakt_configured @@ -137,16 +150,22 @@ def validate_libraries(self): f"Invalid action_mode '{library['action_mode']}' in library '{library['name']}', it should be either 'delete'." ) - if 'watch_status' in library and library['watch_status'] not in ['watched', 'unwatched']: + if "watch_status" in library and library["watch_status"] not in [ + "watched", + "unwatched", + ]: self.log_and_exit( - self.log_and_exit( - f"Invalid watch_status '{library.get('watch_status')}' in library " - f"'{library.get('name')}', it must be either 'watched', 'unwatched', " - "or not set." - ) + self.log_and_exit( + f"Invalid watch_status '{library.get('watch_status')}' in library " + f"'{library.get('name')}', it must be either 'watched', 'unwatched', " + "or not set." + ) ) - if 'watch_status' in library and 'apply_last_watch_threshold_to_collections' in library: + if ( + "watch_status" in library + and "apply_last_watch_threshold_to_collections" in library + ): self.log_and_exit( self.log_and_exit( f"'apply_last_watch_threshold_to_collections' cannot be used when " @@ -156,9 +175,9 @@ def validate_libraries(self): ) ) - if sort_config := library.get('sort', {}): - sort_field = sort_config.get('field') - sort_order = sort_config.get('order') + if sort_config := library.get("sort", {}): + sort_field = sort_config.get("field") + sort_order = sort_config.get("order") if sort_field and sort_field not in VALID_SORT_FIELDS: self.log_and_exit( diff --git a/app/constants.py b/app/constants.py index b447021..67313ca 100644 --- a/app/constants.py +++ b/app/constants.py @@ -1,8 +1,17 @@ # Valid sort fields -VALID_SORT_FIELDS = ['title', 'size', 'release_year', 'runtime', 'added_date', 'rating', "seasons", "episodes"] +VALID_SORT_FIELDS = [ + "title", + "size", + "release_year", + "runtime", + "added_date", + "rating", + "seasons", + "episodes", +] # Valid sort orders -VALID_SORT_ORDERS = ['asc', 'desc'] +VALID_SORT_ORDERS = ["asc", "desc"] # Valid action modes -VALID_ACTION_MODES = ['delete'] \ No newline at end of file +VALID_ACTION_MODES = ["delete"] diff --git a/app/logger.py b/app/logger.py index 0b766f8..3a9fbed 100644 --- a/app/logger.py +++ b/app/logger.py @@ -11,6 +11,7 @@ # Deleterr logger logger = logging.getLogger("deleterr") + class LogLevelFilter(logging.Filter): def __init__(self, max_level): super(LogLevelFilter, self).__init__() @@ -19,7 +20,8 @@ def __init__(self, max_level): def filter(self, record): return record.levelno <= self.max_level - + + def initLogger(console=False, log_dir=False, verbose=False): """ Setup logging for Deleterr. It uses the logger instance with the name @@ -50,11 +52,16 @@ def initLogger(console=False, log_dir=False, verbose=False): # Setup file logger if log_dir: - file_formatter = logging.Formatter('%(asctime)s - %(levelname)-7s :: %(filename)s :: %(name)s : %(message)s', '%Y-%m-%d %H:%M:%S') + file_formatter = logging.Formatter( + "%(asctime)s - %(levelname)-7s :: %(filename)s :: %(name)s : %(message)s", + "%Y-%m-%d %H:%M:%S", + ) # Main logger filename = os.path.join(log_dir, FILENAME) - file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE, backupCount=MAX_FILES, encoding='utf-8') + file_handler = handlers.RotatingFileHandler( + filename, maxBytes=MAX_SIZE, backupCount=MAX_FILES, encoding="utf-8" + ) file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(file_formatter) @@ -62,7 +69,10 @@ def initLogger(console=False, log_dir=False, verbose=False): # Setup console logger if console: - console_formatter = logging.Formatter('%(asctime)s - %(levelname)s :: %(filename)s :: %(name)s : %(message)s', '%Y-%m-%d %H:%M:%S') + console_formatter = logging.Formatter( + "%(asctime)s - %(levelname)s :: %(filename)s :: %(name)s : %(message)s", + "%Y-%m-%d %H:%M:%S", + ) stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setFormatter(console_formatter) @@ -76,6 +86,7 @@ def initLogger(console=False, log_dir=False, verbose=False): logger.addHandler(stdout_handler) logger.addHandler(stderr_handler) + # Expose logger methods # Main logger info = logger.info @@ -83,4 +94,4 @@ def initLogger(console=False, log_dir=False, verbose=False): error = logger.error debug = logger.debug warning = logger.warning -exception = logger.exception \ No newline at end of file +exception = logger.exception diff --git a/app/modules/tautulli.py b/app/modules/tautulli.py index 11fa7bf..c16e451 100644 --- a/app/modules/tautulli.py +++ b/app/modules/tautulli.py @@ -96,5 +96,5 @@ def _prepare_activity_entry(self, entry, metadata): return { "last_watched": datetime.fromtimestamp(entry["stopped"]), "title": metadata["title"], - "year": int(metadata.get("year") or 0) + "year": int(metadata.get("year") or 0), } diff --git a/app/modules/trakt.py b/app/modules/trakt.py index 59a6033..f1f0467 100644 --- a/app/modules/trakt.py +++ b/app/modules/trakt.py @@ -65,7 +65,7 @@ def _fetch_user_list_items( f"Traktpy does not support {listname} {media_type}s. Skipping..." ) return [] - + return trakt.Trakt["users/*/lists/*"].items( username, listname, diff --git a/app/utils.py b/app/utils.py index 3778bdc..6921482 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,22 +1,23 @@ def print_readable_freed_space(saved_space): units = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] index = 0 - + while saved_space >= 1024 and index < len(units) - 1: saved_space /= 1024 index += 1 - + return f"{saved_space:.2f} {units[index]}" + def parse_size_to_bytes(size_str): units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] - unit = ''.join([i for i in size_str if not i.isdigit() and not i == '.']) - size = ''.join([i for i in size_str if i.isdigit() or i == '.']) + unit = "".join([i for i in size_str if not i.isdigit() and not i == "."]) + size = "".join([i for i in size_str if i.isdigit() or i == "."]) size = float(size) index = units.index(unit) - + while index > 0: size *= 1024 index -= 1 - - return int(size) \ No newline at end of file + + return int(size) diff --git a/tests/test_config.py b/tests/test_config.py index 76cac3f..d502209 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,64 +2,67 @@ from app.config import Config from app.constants import VALID_SORT_FIELDS, VALID_SORT_ORDERS, VALID_ACTION_MODES + # Test case for validate_libraries -@pytest.mark.parametrize("library_config, expected_exit_code", [ - ( - { - "name": "TV Shows", - "action_mode": "delete", - }, - True - ), - ( - { - "name": "TV Shows", - "action_mode": "invalid_mode", - }, - False # Expect False as the action_mode is invalid - ), -]) +@pytest.mark.parametrize( + "library_config, expected_exit_code", + [ + ( + { + "name": "TV Shows", + "action_mode": "delete", + }, + True, + ), + ( + { + "name": "TV Shows", + "action_mode": "invalid_mode", + }, + False, # Expect False as the action_mode is invalid + ), + ], +) def test_validate_action_modes(library_config, expected_exit_code): validator = Config({"libraries": [library_config]}) - + with pytest.raises(SystemExit) as exc_info: validator.validate_libraries() - + assert exc_info.value.code == expected_exit_code + # Test case for validate_libraries -@pytest.mark.parametrize("library_config, expected_exit_code", [ - ( - { - "name": "TV Shows", - "action_mode": "delete", - "sort": { - "field": "invalid_field", - "order": "asc" - } - }, - 1 - ), - ( - { - "name": "TV Shows", - "action_mode": "delete", - "sort": { - "field": "title", - "order": "invalid_order" - } - }, - 1 - ), -]) +@pytest.mark.parametrize( + "library_config, expected_exit_code", + [ + ( + { + "name": "TV Shows", + "action_mode": "delete", + "sort": {"field": "invalid_field", "order": "asc"}, + }, + 1, + ), + ( + { + "name": "TV Shows", + "action_mode": "delete", + "sort": {"field": "title", "order": "invalid_order"}, + }, + 1, + ), + ], +) def test_invalid_sorting_options(library_config, expected_exit_code): validator = Config({"libraries": [library_config]}) - + with pytest.raises(SystemExit) as exc_info: validator.validate_libraries() - + assert exc_info.value.code == expected_exit_code + # Test case for validate_libraries @pytest.mark.parametrize("sort_field", VALID_SORT_FIELDS) @pytest.mark.parametrize("sort_order", VALID_SORT_ORDERS) @@ -68,11 +71,8 @@ def test_valid_sorting_options(sort_field, sort_order): "name": "TV Shows", "action_mode": "delete", "sonarr": "test", - "sort": { - "field": sort_field, - "order": sort_order - } + "sort": {"field": sort_field, "order": sort_order}, } - + validator = Config({"libraries": [library_config]}) - assert validator.validate_libraries() == True \ No newline at end of file + assert validator.validate_libraries() == True diff --git a/tests/test_deleterr.py b/tests/test_deleterr.py index 50dfe4c..d100476 100644 --- a/tests/test_deleterr.py +++ b/tests/test_deleterr.py @@ -7,21 +7,19 @@ class TestLibraryMeetsDiskSpaceThreshold(unittest.TestCase): def setUp(self): self.pyarr = Mock() self.library = { - "disk_size_threshold": [ - {"path": "/data/media/local", "threshold": "1TB"} - ], - "name": "Test Library" + "disk_size_threshold": [{"path": "/data/media/local", "threshold": "1TB"}], + "name": "Test Library", } def test_meets_threshold(self): self.pyarr.get_disk_space.return_value = [ - {"path": "/data/media/local", "freeSpace": 500000000000 } # 500GB + {"path": "/data/media/local", "freeSpace": 500000000000} # 500GB ] self.assertTrue(library_meets_disk_space_threshold(self.library, self.pyarr)) def test_does_not_meet_threshold(self): self.pyarr.get_disk_space.return_value = [ - {"path": "/data/media/local", "freeSpace": 2000000000000 } # 2TB + {"path": "/data/media/local", "freeSpace": 2000000000000} # 2TB ] self.assertFalse(library_meets_disk_space_threshold(self.library, self.pyarr)) @@ -31,6 +29,7 @@ def test_folder_not_found(self): ] self.assertFalse(library_meets_disk_space_threshold(self.library, self.pyarr)) + class TestFindWatchedData(unittest.TestCase): def setUp(self): self.activity_data = { @@ -44,32 +43,44 @@ def test_guid_in_activity_data(self): plex_media_item.guid = "guid1" plex_media_item.title = "Title1" plex_media_item.year = 2000 - self.assertEqual(find_watched_data(plex_media_item, self.activity_data), self.activity_data["guid1"]) + self.assertEqual( + find_watched_data(plex_media_item, self.activity_data), + self.activity_data["guid1"], + ) def test_guid_in_guid(self): plex_media_item = Mock() plex_media_item.guid = "guid1" plex_media_item.title = "Title4" plex_media_item.year = 2003 - self.assertEqual(find_watched_data(plex_media_item, self.activity_data), self.activity_data["guid1"]) + self.assertEqual( + find_watched_data(plex_media_item, self.activity_data), + self.activity_data["guid1"], + ) def test_title_match(self): plex_media_item = Mock() plex_media_item.guid = "guid2" plex_media_item.title = "Title2" plex_media_item.year = 2001 - self.assertEqual(find_watched_data(plex_media_item, self.activity_data), self.activity_data["guid2"]) + self.assertEqual( + find_watched_data(plex_media_item, self.activity_data), + self.activity_data["guid2"], + ) def test_year_difference_less_than_one(self): plex_media_item = Mock() plex_media_item.guid = "guid4" plex_media_item.title = "Title3" plex_media_item.year = 2003 - self.assertEqual(find_watched_data(plex_media_item, self.activity_data), self.activity_data["guid3"]) + self.assertEqual( + find_watched_data(plex_media_item, self.activity_data), + self.activity_data["guid3"], + ) def test_no_match(self): plex_media_item = Mock() plex_media_item.guid = "guid4" plex_media_item.title = "Title4" plex_media_item.year = 2004 - self.assertIsNone(find_watched_data(plex_media_item, self.activity_data)) \ No newline at end of file + self.assertIsNone(find_watched_data(plex_media_item, self.activity_data)) diff --git a/tests/test_exclusion.py b/tests/test_exclusion.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_sort_media.py b/tests/test_sort_media.py index ca73a5e..48e85eb 100644 --- a/tests/test_sort_media.py +++ b/tests/test_sort_media.py @@ -1,36 +1,81 @@ import pytest -from app.deleterr import sort_media +from app.deleterr import sort_media + @pytest.fixture def media_list(): return [ - {"sortTitle": "B", "sizeOnDisk": 5000, "year": 2020, "runtime": 120, "added": "2023-01-01", "ratings": {"imdb": {"value": 8}}, "statistics": {"seasonCount": 2, "totalEpisodeCount": 25}}, - {"sortTitle": "A", "sizeOnDisk": 2000, "year": 2019, "runtime": 110, "added": "2023-01-02", "ratings": {"tmdb": {"value": 7}}, "statistics": {"seasonCount": 3, "totalEpisodeCount": 30}}, - {"sortTitle": "C", "sizeOnDisk": 3000, "year": 2021, "runtime": 115, "added": "2023-01-03", "ratings": {"value": 9}, "statistics": {"seasonCount": 1, "totalEpisodeCount": 20}}, - {"sortTitle": "D", "sizeOnDisk": 1000, "year": 2018, "runtime": 125, "added": "2023-01-04", "ratings": {"imdb": {"value": 6}}, "statistics": {"seasonCount": 5, "totalEpisodeCount": 50}}, - {"sortTitle": "E", "sizeOnDisk": 6000, "year": 2022, "runtime": 130, "added": "2023-01-05", "ratings": {"value": 10}, "statistics": {"seasonCount": 4, "totalEpisodeCount": 40}}, + { + "sortTitle": "B", + "sizeOnDisk": 5000, + "year": 2020, + "runtime": 120, + "added": "2023-01-01", + "ratings": {"imdb": {"value": 8}}, + "statistics": {"seasonCount": 2, "totalEpisodeCount": 25}, + }, + { + "sortTitle": "A", + "sizeOnDisk": 2000, + "year": 2019, + "runtime": 110, + "added": "2023-01-02", + "ratings": {"tmdb": {"value": 7}}, + "statistics": {"seasonCount": 3, "totalEpisodeCount": 30}, + }, + { + "sortTitle": "C", + "sizeOnDisk": 3000, + "year": 2021, + "runtime": 115, + "added": "2023-01-03", + "ratings": {"value": 9}, + "statistics": {"seasonCount": 1, "totalEpisodeCount": 20}, + }, + { + "sortTitle": "D", + "sizeOnDisk": 1000, + "year": 2018, + "runtime": 125, + "added": "2023-01-04", + "ratings": {"imdb": {"value": 6}}, + "statistics": {"seasonCount": 5, "totalEpisodeCount": 50}, + }, + { + "sortTitle": "E", + "sizeOnDisk": 6000, + "year": 2022, + "runtime": 130, + "added": "2023-01-05", + "ratings": {"value": 10}, + "statistics": {"seasonCount": 4, "totalEpisodeCount": 40}, + }, ] -@pytest.mark.parametrize("sort_field, sort_order, expected_order", [ - ("title", "asc", ["A", "B", "C", "D", "E"]), - ("title", "desc", ["E", "D", "C", "B", "A"]), - ("size", "asc", ["D", "A", "C", "B", "E"]), - ("size", "desc", ["E", "B", "C", "A", "D"]), - ("release_year", "asc", ["D", "A", "B", "C", "E"]), - ("release_year", "desc", ["E", "C", "B", "A", "D"]), - ("runtime", "asc", ["A", "C", "B", "D", "E"]), - ("runtime", "desc", ["E", "D", "B", "C", "A"]), - ("added_date", "asc", ["B", "A", "C", "D", "E"]), - ("added_date", "desc", ["E", "D", "C", "A", "B"]), - ("rating", "asc", ["D", "A", "B", "C", "E"]), - ("rating", "desc", ["E", "C", "B", "A", "D"]), - ("seasons", "asc", ["C", "B", "A", "E", "D"]), - ("seasons", "desc", ["D", "E", "A", "B", "C"]), - ("episodes", "asc", ["C", "B", "A", "E", "D"]), - ("episodes", "desc", ["D", "E", "A", "B", "C"]), -]) + +@pytest.mark.parametrize( + "sort_field, sort_order, expected_order", + [ + ("title", "asc", ["A", "B", "C", "D", "E"]), + ("title", "desc", ["E", "D", "C", "B", "A"]), + ("size", "asc", ["D", "A", "C", "B", "E"]), + ("size", "desc", ["E", "B", "C", "A", "D"]), + ("release_year", "asc", ["D", "A", "B", "C", "E"]), + ("release_year", "desc", ["E", "C", "B", "A", "D"]), + ("runtime", "asc", ["A", "C", "B", "D", "E"]), + ("runtime", "desc", ["E", "D", "B", "C", "A"]), + ("added_date", "asc", ["B", "A", "C", "D", "E"]), + ("added_date", "desc", ["E", "D", "C", "A", "B"]), + ("rating", "asc", ["D", "A", "B", "C", "E"]), + ("rating", "desc", ["E", "C", "B", "A", "D"]), + ("seasons", "asc", ["C", "B", "A", "E", "D"]), + ("seasons", "desc", ["D", "E", "A", "B", "C"]), + ("episodes", "asc", ["C", "B", "A", "E", "D"]), + ("episodes", "desc", ["D", "E", "A", "B", "C"]), + ], +) def test_sort_media(media_list, sort_field, sort_order, expected_order): sort_config = {"field": sort_field, "order": sort_order} sorted_list = sort_media(media_list, sort_config) actual_order = [item["sortTitle"] for item in sorted_list] - assert actual_order == expected_order \ No newline at end of file + assert actual_order == expected_order diff --git a/tests/test_tautulli.py b/tests/test_tautulli.py index 96e4133..c1fd736 100644 --- a/tests/test_tautulli.py +++ b/tests/test_tautulli.py @@ -42,4 +42,4 @@ def test_calculate_min_date(library_config, expected_days): tautulli = Tautulli("url", "api_key") result = tautulli._calculate_min_date(library_config) - assert result.date() == expected.date() \ No newline at end of file + assert result.date() == expected.date() diff --git a/tests/test_utils.py b/tests/test_utils.py index 4e17a9f..0355ddb 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,26 +1,38 @@ import pytest from app.utils import print_readable_freed_space, parse_size_to_bytes -@pytest.mark.parametrize("input_size, expected_output", [ - (500, "500.00 Bytes"), - (1024, "1.00 KB"), - (1048576, "1.00 MB"), # 1024 * 1024 - (1073741824, "1.00 GB"), # 1024 * 1024 * 1024 - (1099511627776, "1.00 TB"), # 1024 * 1024 * 1024 * 1024 - (1125899906842624, "1.00 PB") # 1024^5 -]) + +@pytest.mark.parametrize( + "input_size, expected_output", + [ + (500, "500.00 Bytes"), + (1024, "1.00 KB"), + (1048576, "1.00 MB"), # 1024 * 1024 + (1073741824, "1.00 GB"), # 1024 * 1024 * 1024 + (1099511627776, "1.00 TB"), # 1024 * 1024 * 1024 * 1024 + (1125899906842624, "1.00 PB"), # 1024^5 + ], +) def test_print_readable_freed_space(input_size, expected_output): result = print_readable_freed_space(input_size) - assert result == expected_output, f"For {input_size}, expected {expected_output} but got {result}" + assert ( + result == expected_output + ), f"For {input_size}, expected {expected_output} but got {result}" + -@pytest.mark.parametrize("input_size, expected_output", [ - ('2TB', 2199023255552), - ('5.6GB', 6012954214), - ("1GB", 1073741824), - ('230MB', 241172480), - ('1B', 1), - ('0KB', 0), -]) +@pytest.mark.parametrize( + "input_size, expected_output", + [ + ("2TB", 2199023255552), + ("5.6GB", 6012954214), + ("1GB", 1073741824), + ("230MB", 241172480), + ("1B", 1), + ("0KB", 0), + ], +) def test_parse_size_to_bytes(input_size, expected_output): result = parse_size_to_bytes(input_size) - assert result == expected_output, f"For {input_size}, expected {expected_output} but got {result}" \ No newline at end of file + assert ( + result == expected_output + ), f"For {input_size}, expected {expected_output} but got {result}" From 1da901bf142249e9b3620388c4bec6d7c7c91d3a Mon Sep 17 00:00:00 2001 From: Rodrigo Braz Date: Mon, 19 Feb 2024 18:00:28 +0000 Subject: [PATCH 10/12] chore: Add disk size unit validation --- app/config.py | 12 ++++++++++++ app/deleterr.py | 2 +- app/utils.py | 20 ++++++++++++++------ tests/test_utils.py | 31 +++++++++++++++++++++++++++++-- 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/app/config.py b/app/config.py index c3804bc..70825e6 100644 --- a/app/config.py +++ b/app/config.py @@ -8,6 +8,7 @@ from tautulli import RawAPI from app.modules.trakt import Trakt from app.constants import VALID_SORT_FIELDS, VALID_SORT_ORDERS, VALID_ACTION_MODES +from app.utils import validate_units def load_config(config_file): @@ -137,6 +138,17 @@ def validate_libraries(self): f"Neither 'sonarr' nor 'radarr' is configured for library '{library.get('name')}'. Please check your configuration." ) + for item in library.get("disk_size_threshold", []): + path = item.get("path") + threshold = item.get("threshold") + + try: + validate_units(threshold) + except ValueError as err: + self.log_and_exit( + f"Invalid threshold '{threshold}' for path '{path}' in library '{library.get('name')}': {err}" + ) + if ( len(library.get("exclude", {}).get("trakt_lists", [])) > 0 and not trakt_configured diff --git a/app/deleterr.py b/app/deleterr.py index 6c11b53..fdc5acd 100644 --- a/app/deleterr.py +++ b/app/deleterr.py @@ -735,7 +735,7 @@ def main(): logger.info("Running version %s", get_file_contents("/app/commit_tag.txt")) logger.info("Log level set to %s", log_level) - config = load_config("config/settings.yaml") + config = load_config("/config/settings.yaml") config.validate() Deleterr(config) diff --git a/app/utils.py b/app/utils.py index 6921482..827744d 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,23 +1,31 @@ +valid_units = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] + + def print_readable_freed_space(saved_space): - units = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] index = 0 - while saved_space >= 1024 and index < len(units) - 1: + while saved_space >= 1024 and index < len(valid_units) - 1: saved_space /= 1024 index += 1 - return f"{saved_space:.2f} {units[index]}" + return f"{saved_space:.2f} {valid_units[index]}" def parse_size_to_bytes(size_str): - units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] - unit = "".join([i for i in size_str if not i.isdigit() and not i == "."]) + unit = "".join([i for i in size_str if not i.isdigit() and not i == "."]).strip() size = "".join([i for i in size_str if i.isdigit() or i == "."]) size = float(size) - index = units.index(unit) + index = valid_units.index(unit) while index > 0: size *= 1024 index -= 1 return int(size) + + +def validate_units(threshold): + unit = "".join([i for i in threshold if not i.isdigit() and not i == "."]).strip() + + if unit not in valid_units: + raise ValueError(f"Invalid unit '{unit}'. Valid units are {valid_units}") diff --git a/tests/test_utils.py b/tests/test_utils.py index 0355ddb..4fb26f1 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,11 +1,11 @@ import pytest -from app.utils import print_readable_freed_space, parse_size_to_bytes +from app.utils import print_readable_freed_space, parse_size_to_bytes, validate_units @pytest.mark.parametrize( "input_size, expected_output", [ - (500, "500.00 Bytes"), + (500, "500.00 B"), (1024, "1.00 KB"), (1048576, "1.00 MB"), # 1024 * 1024 (1073741824, "1.00 GB"), # 1024 * 1024 * 1024 @@ -24,8 +24,11 @@ def test_print_readable_freed_space(input_size, expected_output): "input_size, expected_output", [ ("2TB", 2199023255552), + ("2.00TB", 2199023255552), ("5.6GB", 6012954214), ("1GB", 1073741824), + ("1 GB", 1073741824), + ("1.00 GB", 1073741824), ("230MB", 241172480), ("1B", 1), ("0KB", 0), @@ -36,3 +39,27 @@ def test_parse_size_to_bytes(input_size, expected_output): assert ( result == expected_output ), f"For {input_size}, expected {expected_output} but got {result}" + + +def test_validate_units_invalid(): + with pytest.raises(ValueError): + validate_units("10T") + + +@pytest.mark.parametrize( + "input_string", + [ + "10B", + "20KB", + "30MB", + "40GB", + "50 TB", + "60 PB", + "60 EB", + ], +) +def test_validate_units_valid(input_string): + try: + validate_units(input_string) + except ValueError: + pytest.fail("Unexpected ValueError ..") From 6d1ed566a0cbe3457f0d725fc9ffe07b1a569588 Mon Sep 17 00:00:00 2001 From: Rodrigo Braz Date: Mon, 19 Feb 2024 21:58:48 +0000 Subject: [PATCH 11/12] chore: Update documention regarding disk_size_threshold --- config/settings.yaml.example | 3 +++ docs/CONFIGURATION.md | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/config/settings.yaml.example b/config/settings.yaml.example index 9d10c83..9063c56 100644 --- a/config/settings.yaml.example +++ b/config/settings.yaml.example @@ -56,6 +56,9 @@ libraries: apply_last_watch_threshold_to_collections: true # If true, the last watched threshold will be applied to all other items in the collection added_at_threshold: 90 # Media not added in this period will be subject to actions max_actions_per_run: 10 # Maximum number of actions to perform per run + disk_size_threshold: # Will only trigger if the available disk size is below this + - path: "/data/media/local" + threshold: 1TB sort: field: year # Deleter older movies first order: asc diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8cf6a16..2217d9a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -148,9 +148,10 @@ 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` | - | -| `watch_status` | Watch status. Media not in this is state will not be actionable | `-` | `wathced`, `unwatched` | +| `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` | | `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`
`threshold: 1TB` | [`B`, `KB`, `MB`, `GB`, `TB`, `PB`, `EB`] | | `max_actions_per_run` | Limit the number of actions performed per run. Defaults to `10` | `3000` | - | | `sort_config.field` | Field to sort media list by. Defaults to `title` | `title` | `title`, `size`, `release_year`, `runtime`, `added_date`, `rating`, `episodes`, `seasons` | | `sort_config.order` | Direction to sort media list by. Defaults to `asc` | `asc` | `asc`, `desc` | From d100472af757c15642f86116beedc060d044b386 Mon Sep 17 00:00:00 2001 From: Rodrigo Braz Date: Mon, 19 Feb 2024 22:00:10 +0000 Subject: [PATCH 12/12] chore: Fix typo --- config/settings.yaml.example | 2 +- docs/CONFIGURATION.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/settings.yaml.example b/config/settings.yaml.example index 9063c56..8dd6cdb 100644 --- a/config/settings.yaml.example +++ b/config/settings.yaml.example @@ -123,7 +123,7 @@ libraries: sonarr: Sonarr series_type: anime sort: - field: episodes # Deleter animes with more episodes first + field: episodes # Delete animes with more episodes first order: desc exclude: titles: ["Dragon Ball", "Dragon Ball Z", "Dragon Ball GT"] diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2217d9a..0805702 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -151,7 +151,7 @@ For each of your Plex libraries, specify how you want Deleterr to behave. Define | `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` | | `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`
`threshold: 1TB` | [`B`, `KB`, `MB`, `GB`, `TB`, `PB`, `EB`] | +| `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`
`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` | - | | `sort_config.field` | Field to sort media list by. Defaults to `title` | `title` | `title`, `size`, `release_year`, `runtime`, `added_date`, `rating`, `episodes`, `seasons` | | `sort_config.order` | Direction to sort media list by. Defaults to `asc` | `asc` | `asc`, `desc` |