From 60e1ebf93d541c14d93c747d85c6e90d4a24bae6 Mon Sep 17 00:00:00 2001 From: Brad Holland Date: Wed, 22 May 2024 02:32:26 -0400 Subject: [PATCH] fixes #26 - add analyze_files option (#27) --- README.md | 28 ++++++++++-------- docker/config.yml.example | 2 +- poetry.lock | 6 ++-- pyproject.toml | 6 ++++ src/config_schema.py | 3 +- src/existing_renamer.py | 49 +++++++++++++++++++++++++++++++- src/main.py | 5 ++-- tests/conftest.py | 10 +++++++ tests/test_existing_renamer.py | 52 ++++++++++++++++++++++++++++++++-- tests/test_main.py | 24 +++++++++++++++- 10 files changed, 162 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 5f94769..4a0fcd8 100644 --- a/README.md +++ b/README.md @@ -53,23 +53,27 @@ This job uses the [Sonarr API](https://sonarr.tv/docs/api/) to do the following * Checks if any episodes need to be [renamed](https://sonarr.tv/docs/api/#/RenameEpisode/get_api_v3_rename) * Triggers a rename on any episodes that need be renamed (per series) +#### Analyze Files +This config option is useful if you have audio/video codec information as part of your mediaformat, and you are transcoding files after import to sonarr. This will initiate a rescan of the files in your library, so that the mediainfo will be udpated. Then the renamer will come through and detect changes, and rename the files + ### Usage The application run immediately on startup, and then continue to schedule jobs every hour (+- 5 minutes) after the first execution. ### Configuration -| Name | Type | Required | Default Value | Description | -| ------------------------------------------ | ------- | -------- | ------------- | -------------------------------------------------------------------------------------- | -| `sonarr` | Array | Yes | [] | One or more sonarr instances | -| `sonarr[].name` | string | Yes | N/A | user friendly instance name, used in log messages | -| `sonarr[].url` | string | Yes | N/A | url for sonarr instance | -| `sonarr[].api_key` | string | Yes | N/A | api_key for sonarr instance | -| `sonarr[].series_scanner.enabled` | boolean | Yes | N/A | enables/disables series_scanner functionality | -| `sonarr[].series_scanner.hourly_job` | boolean | Yes | N/A | disables hourly job. App will exit after first execution | -| `sonarr[].series_scanner.hours_before_air` | integer | No | 4 | The number of hours before an episode has aired, to trigger a rescan when title is TBA | -| `sonarr[].existing_renamer.enabled` | boolean | Yes | N/A | enables/disables existing_renamer functionality | -| `sonarr[].existing_renamer.hourly_job` | boolean | Yes | N/A | disables hourly job. App will exit after first execution | +| Name | Type | Required | Default Value | Description | +| ------------------------------------------ | ------- | -------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `sonarr` | Array | Yes | [] | One or more sonarr instances | +| `sonarr[].name` | string | Yes | N/A | user friendly instance name, used in log messages | +| `sonarr[].url` | string | Yes | N/A | url for sonarr instance | +| `sonarr[].api_key` | string | Yes | N/A | api_key for sonarr instance | +| `sonarr[].series_scanner.enabled` | boolean | No | False | enables/disables series_scanner functionality | +| `sonarr[].series_scanner.hourly_job` | boolean | No | False | disables hourly job. App will exit after first execution | +| `sonarr[].series_scanner.hours_before_air` | integer | No | 4 | The number of hours before an episode has aired, to trigger a rescan when title is TBA | +| `sonarr[].existing_renamer.enabled` | boolean | No | False | enables/disables existing_renamer functionality | +| `sonarr[].existing_renamer.hourly_job` | boolean | No | False | disables hourly job. App will exit after first execution | +| `sonarr[].existing_renamer.analyze_files` | boolean | No | False | This will initiate a rescan of the files in your library. This is helpful if you are transcoding files, and the audio/video codecs have changed. | ### Local Setup #### devcontainer @@ -89,5 +93,5 @@ $ poetry run python src/main.py #### Unit Tests ```shell -$ pytest --cov=src --cov-report=html tests --cov-branch +$ pytest ``` diff --git a/docker/config.yml.example b/docker/config.yml.example index 31d4357..ef4ecfa 100644 --- a/docker/config.yml.example +++ b/docker/config.yml.example @@ -8,6 +8,7 @@ sonarr: existing_renamer: enabled: False hourly_job: False + analyze_files: False - name: anime url: https://sonarr-anime.tld:8989 api_key: not-a-real-api-key @@ -18,4 +19,3 @@ sonarr: existing_renamer: enabled: True hourly_job: True - diff --git a/poetry.lock b/poetry.lock index a4eb2b5..b285eaf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -306,13 +306,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pycliarr" -version = "1.0.26" +version = "1.0.27" description = "Python client for radarr and sonarr" optional = false python-versions = ">=3.6" files = [ - {file = "pycliarr-1.0.26-py3-none-any.whl", hash = "sha256:9c2d2794bc5b1dffc26a7bf006a2046d44beb948ca19fe87807081b94323830b"}, - {file = "pycliarr-1.0.26.tar.gz", hash = "sha256:05d8cb77ceaf62efbf7e5c2e3e6e59961086c5c2cad11ebf15046aa5d0bb39a4"}, + {file = "pycliarr-1.0.27-py3-none-any.whl", hash = "sha256:f7cb1d2d9469ebaf9a329128bb0181f3bb363a1a85fe2baeada6c9dd49674194"}, + {file = "pycliarr-1.0.27.tar.gz", hash = "sha256:cbf2daec0bf02cc37ba0f3f6c560147532a71b658dc48221bda97ea0e625c4a8"}, ] [package.dependencies] diff --git a/pyproject.toml b/pyproject.toml index 505e2a9..27638ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,12 @@ testpaths = [ "./tests/models" ] addopts = [ + "--cov=src", + "tests", + "--cov-branch", + "--capture=sys", + "--cov-report=xml", + "--cov-report=html", "--import-mode=importlib", ] mock_use_standalone_module = "True" diff --git a/src/config_schema.py b/src/config_schema.py index 6466dd9..2d3032c 100644 --- a/src/config_schema.py +++ b/src/config_schema.py @@ -38,11 +38,12 @@ }, Optional( "existing_renamer", - default=dict(enabled=False, hourly_job=False), + default=dict(enabled=False, hourly_job=False, analyze_files=False), ignore_extra_keys=True, ): { Optional("enabled", default=False): bool, Optional("hourly_job", default=False): bool, + Optional("analyze_files", default=False): bool, }, } ], diff --git a/src/existing_renamer.py b/src/existing_renamer.py index 4072ce2..c21962b 100644 --- a/src/existing_renamer.py +++ b/src/existing_renamer.py @@ -1,3 +1,4 @@ +from time import sleep from typing import List from loguru import logger @@ -7,14 +8,27 @@ class ExistingRenamer: - def __init__(self, name, url, api_key): + def __init__(self, name: str, url: str, api_key: str, analyze_files: bool = False): self.name = name self.sonarr_cli = SonarrCli(url, api_key) + self.analyze_files = analyze_files def scan(self): with logger.contextualize(instance=self.name): logger.info("Starting Existing Renamer") + if self.analyze_files: + if not self.__analyze_files_enabled(): + logger.warning( + "Analyse video files is not enabled, please enable setting, in order to use the reanalyze_files feature" + ) + else: + logger.info("Initiated disk scan of library") + if self.__analyze_files(): + logger.info("disk scan finished successfully") + else: + logger.info("disk scan failed") + series = self.sonarr_cli.get_serie() if len(series) == 0: @@ -50,3 +64,36 @@ def scan(self): ) logger.info("Finished Existing Renamer") + + def __analyze_files(self) -> bool: + """_summary_ + + Returns: + bool: if disk scan succeeded + """ + rescan_command = self.sonarr_cli._sendCommand( + { + "name": "RescanSeries", + "priority": "high", + } + ) + resp: json_data = {} + + # sonarr commands have to be polled for completion status + while resp.get("status") != "completed": + sleep(10) + resp = self.sonarr_cli.get_command(cid=rescan_command["id"]) + + return resp["result"] == "successful" + + def __analyze_files_enabled(self) -> bool: + """_summary_ + + Returns: + bool: if analyze_files is enabled + """ + mediamanagement: json_data = self.sonarr_cli.request_get( + path="/api/v3/config/mediamanagement" + ) + + return mediamanagement["enableMediaInfo"] diff --git a/src/main.py b/src/main.py index 29f7a08..668ce88 100644 --- a/src/main.py +++ b/src/main.py @@ -51,6 +51,7 @@ def __existing_renamer_job(self, sonarr_config): name=sonarr_config.name, url=sonarr_config.url, api_key=sonarr_config.api_key, + analyze_files=sonarr_config.existing_renamer.analyze_files, ).scan() except CliArrError as exc: logger.error(exc) @@ -99,5 +100,5 @@ def start(self) -> None: sleep(1) -if __name__ == "__main__": - Main().start() +if __name__ == "__main__": # pragma nocover + Main().start() # pragma: no cover diff --git a/tests/conftest.py b/tests/conftest.py index e8d2243..4ac021d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,11 @@ def get_serie(mocker) -> None: mocker.patch.object(SonarrCli, "get_serie").return_value = series +@pytest.fixture +def get_serie_empty(mocker) -> None: + mocker.patch.object(SonarrCli, "get_serie").return_value = [] + + @pytest.fixture def mock_loguru_error(mocker) -> None: return mocker.patch.object(logger, "error") @@ -30,6 +35,11 @@ def mock_loguru_debug(mocker) -> None: return mocker.patch.object(logger, "debug") +@pytest.fixture +def mock_loguru_warning(mocker) -> None: + return mocker.patch.object(logger, "warning") + + def episode_data( id: int, title: str, diff --git a/tests/test_existing_renamer.py b/tests/test_existing_renamer.py index 65cf250..8ab56fe 100644 --- a/tests/test_existing_renamer.py +++ b/tests/test_existing_renamer.py @@ -1,12 +1,12 @@ import logging +from unittest.mock import call from existing_renamer import ExistingRenamer from pycliarr.api import SonarrCli class TestExistingRenamer: - def test_no_series_returned(self, caplog, mocker) -> None: - mocker.patch.object(SonarrCli, "get_serie").return_value = [] + def test_no_series_returned(self, get_serie_empty, caplog, mocker) -> None: rename_files = mocker.patch.object(SonarrCli, "rename_files") with caplog.at_level(logging.DEBUG): @@ -56,3 +56,51 @@ def test_when_multiple_episodes_need_renamed( assert "Found episodes to be renamed" in caplog.text assert "Renaming S01E01, S01E02" in caplog.text rename_files.assert_called_once_with([1, 2], 1) + + def test_when_disk_scan_enabled_and_analyze_files_is_not( + self, get_serie_empty, mock_loguru_warning, mocker + ) -> None: + mocker.patch.object(SonarrCli, "request_get").return_value = dict( + enableMediaInfo=False + ) + + ExistingRenamer("test", "test.tld", "test-api-key", True).scan() + + mock_loguru_warning.assert_called_once_with( + "Analyse video files is not enabled, please enable setting, in order to use the reanalyze_files feature" + ) + + def test_when_disk_scan_enabled( + self, get_serie_empty, mock_loguru_info, mocker + ) -> None: + mocker.patch.object(SonarrCli, "request_get").return_value = dict( + enableMediaInfo=True + ) + mocker.patch.object(SonarrCli, "_sendCommand").return_value = dict(id=1) + mocker.patch.object(SonarrCli, "get_command").return_value = dict( + status="completed", result="successful" + ) + mocker.patch("existing_renamer.sleep").return_value = None + + ExistingRenamer("test", "test.tld", "test-api-key", True).scan() + + assert call("Initiated disk scan of library") in mock_loguru_info.call_args_list + assert ( + call("disk scan finished successfully") in mock_loguru_info.call_args_list + ) + + def test_when_disk_scan_enabled_and_fails( + self, get_serie_empty, mock_loguru_info, mocker + ) -> None: + mocker.patch.object(SonarrCli, "request_get").return_value = dict( + enableMediaInfo=True + ) + mocker.patch.object(SonarrCli, "_sendCommand").return_value = dict(id=1) + mocker.patch.object(SonarrCli, "get_command").return_value = dict( + status="completed", result="failed" + ) + mocker.patch("existing_renamer.sleep").return_value = None + + ExistingRenamer("test", "test.tld", "test-api-key", True).scan() + + assert call("disk scan failed") in mock_loguru_info.call_args_list diff --git a/tests/test_main.py b/tests/test_main.py index 31be4e0..c5769ef 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,7 +3,7 @@ from existing_renamer import ExistingRenamer from main import Main from pycliarr.api import CliArrError -from pyconfigparser import Config, configparser +from pyconfigparser import Config, ConfigError, ConfigFileNotFoundError, configparser from schedule import Job from series_scanner import SeriesScanner @@ -106,3 +106,25 @@ def test_existing_renamer_pycliarr_exception( Main().start() mock_loguru_error.assert_called_once_with(exception) + + def test_config_parser_error(self, mock_loguru_error, capsys, mocker) -> None: + exception = ConfigError("BOOM!") + mocker.patch("pyconfigparser.configparser.get_config").side_effect = exception + + with pytest.raises(SystemExit) as excinfo: + Main().start() + + mock_loguru_error.assert_called_once_with(exception) + assert excinfo.value.code == 1 + + def test_config_file_not_found_error( + self, mock_loguru_error, capsys, mocker + ) -> None: + exception = ConfigFileNotFoundError("BOOM!") + mocker.patch("pyconfigparser.configparser.get_config").side_effect = exception + + with pytest.raises(SystemExit) as excinfo: + Main().start() + + mock_loguru_error.assert_called_once_with(exception) + assert excinfo.value.code == 1