diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e9466cb..21213de 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,10 +5,12 @@ "dockerfile": "Dockerfile" }, "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, "ghcr.io/devcontainers-contrib/features/zsh-plugins:0": { "plugins": "git zsh-syntax-highlighting zsh-autosuggestions poetry poetry-env gpg-agent", "omzPlugins": "https://github.com/zsh-users/zsh-syntax-highlighting https://github.com/zsh-users/zsh-autosuggestions" }, + "ghcr.io/stuartleeks/dev-container-features/shell-history:0": {}, "ghcr.io/devcontainers/features/github-cli:1": {} }, "containerEnv": { @@ -16,7 +18,6 @@ "GH_TOKEN": "${localEnv:GH_TOKEN}" }, "mounts": [ - "source=${localWorkspaceFolder}/.zsh_history,target=/home/poetry/.zsh_history,type=bind,consistency=cached", "source=${localWorkspaceFolder}/config.yml,target=/config/config.yml,type=bind,consistency=cached" ], "postCreateCommand": "zsh .devcontainer/postCreateCommand.sh", @@ -63,7 +64,7 @@ "unittestEnabled": false, "pytestEnabled": true, "pytestArgs": [ - "test" + "tests" ] } }, diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8fab29e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 + +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: sunday + + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + day: sunday diff --git a/.github/workflows/build-test-lint.yaml b/.github/workflows/build-test-lint.yaml index 11f10a5..5725699 100644 --- a/.github/workflows/build-test-lint.yaml +++ b/.github/workflows/build-test-lint.yaml @@ -1,6 +1,13 @@ name: Python package -on: [push] +on: + push: + branches: + - main + pull_request: + branches: + - feat/** + - main jobs: build: @@ -45,7 +52,7 @@ jobs: #---------------------------------------------- - name: Test with pytest run: | - pytest --cov=src --cov-report=xml + pytest --cov-branch --cov=src --cov-report=xml tests #---------------------------------------------- # lint and formatting diff --git a/.gitignore b/.gitignore index b5875c3..da9a553 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,5 @@ - -.zsh_history config.yml -# Ignore dynaconf secret files -.secrets.* - # Created by https://www.toptal.com/developers/gitignore/api/macos,linux,python,pycharm+all,visualstudiocode # Edit at https://www.toptal.com/developers/gitignore?templates=macos,linux,python,pycharm+all,visualstudiocode diff --git a/README.md b/README.md index 47fdf5e..6ffb2cb 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ There is a [devcontainer](https://containers.dev/) provided; it is optional but #### +You will need to create `config.yml` in the root of the repo, to mount your config within the devcontainer + ```shell $ poetry install diff --git a/poetry.lock b/poetry.lock index a959fcd..4c88ece 100644 --- a/poetry.lock +++ b/poetry.lock @@ -262,6 +262,22 @@ files = [ {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, ] +[[package]] +name = "mock" +version = "5.1.0" +description = "Rolling backport of unittest.mock for all Pythons" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744"}, + {file = "mock-5.1.0.tar.gz", hash = "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d"}, +] + +[package.extras] +build = ["blurb", "twine", "wheel"] +docs = ["sphinx"] +test = ["pytest", "pytest-cov"] + [[package]] name = "packaging" version = "24.0" @@ -341,6 +357,40 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +[[package]] +name = "pytest-loguru" +version = "0.4.0" +description = "Pytest Loguru" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_loguru-0.4.0-py3-none-any.whl", hash = "sha256:3cc7b9c6b22cb158209ccbabf0d678dacd3f3c7497d6f46f1c338c13bee1ac77"}, + {file = "pytest_loguru-0.4.0.tar.gz", hash = "sha256:0d9e4e72ae9bfd92f774c666e7353766af11b0b78edd59c290e89be116050f03"}, +] + +[package.dependencies] +loguru = "*" + +[package.extras] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "python-config-parser" version = "3.1.3" @@ -558,4 +608,4 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "8c7413e8f555ffc657a520ec9ef42f55abf6aa7d80c4ce85e944c522f28938a7" +content-hash = "acd17767d395ee17f77074171ba0cea1e1bb9df05d46d374b003853efddf004e" diff --git a/pyproject.toml b/pyproject.toml index 491a418..505e2a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ ruff = "^0.4.4" [tool.poetry.group.test.dependencies] pytest = "^8.2.0" pytest-cov = "^5.0.0" +pytest-mock = "^3.14.0" +mock = "^5.1.0" +pytest-loguru = "^0.4.0" [build-system] requires = ["poetry-core"] @@ -32,13 +35,13 @@ build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] pythonpath = [ "./src", - "./src/config", "./src/models" ] testpaths = [ - "./test", - "./test/models" + "./tests", + "./tests/models" ] addopts = [ "--import-mode=importlib", ] +mock_use_standalone_module = "True" diff --git a/src/config/schema.py b/src/config_schema.py similarity index 100% rename from src/config/schema.py rename to src/config_schema.py diff --git a/src/main.py b/src/main.py index 1b40355..f429598 100644 --- a/src/main.py +++ b/src/main.py @@ -3,7 +3,7 @@ from time import sleep import schedule -from config.schema import CONFIG_SCHEMA +from config_schema import CONFIG_SCHEMA from existing_renamer import ExistingRenamer from loguru import logger from pycliarr.api import CliServerError @@ -11,83 +11,81 @@ from series_scanner import SeriesScanner -def series_scanner_job(sonarr_config): - try: - SeriesScanner( - name=sonarr_config.name, - url=sonarr_config.url, - api_key=sonarr_config.api_key, - hours_before_air=sonarr_config.series_scanner.hours_before_air, - ).scan() - except CliServerError as exc: - logger.error(exc) - - -def schedule_series_scanner(sonarr_config): - series_scanner_job(sonarr_config) - - if sonarr_config.series_scanner.hourly_job: - # Add a random delay of +-5 minutes between jobs - schedule.every(55).to(65).minutes.do( - series_scanner_job, sonarr_config=sonarr_config +class Main: + def __init__(self): + logger_format = ( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | " + "{level} | " + "{name}:{function}:{line} | " + "{extra[instance]} | " + "{extra[series]} | " + "{message}" ) - - -def existing_renamer_job(sonarr_config): - try: - ExistingRenamer( - name=sonarr_config.name, - url=sonarr_config.url, - api_key=sonarr_config.api_key, - ).scan() - except CliServerError as exc: - logger.error(exc) - - -def schedule_existing_renamer(sonarr_config): - existing_renamer_job(sonarr_config) - - if sonarr_config.existing_renamer.hourly_job: - # Add a random delay of +-5 minutes between jobs - schedule.every(55).to(65).minutes.do( - existing_renamer_job, sonarr_config=sonarr_config - ) - - -def loguru_config(): - logger_format = ( - "{time:YYYY-MM-DD HH:mm:ss.SSS} | " - "{level} | " - "{name}:{function}:{line} | " - "{extra[instance]} | " - "{extra[series]} | " - "{message}" - ) - logger.configure(extra={"instance": "", "series": ""}) # Default values - logger.remove() - logger.add(stdout, format=logger_format) + logger.configure(extra={"instance": "", "series": ""}) # Default values + logger.remove() + logger.add(stdout, format=logger_format) + + def __series_scanner_job(self, sonarr_config): + try: + SeriesScanner( + name=sonarr_config.name, + url=sonarr_config.url, + api_key=sonarr_config.api_key, + hours_before_air=sonarr_config.series_scanner.hours_before_air, + ).scan() + except CliServerError as exc: + logger.error(exc) + + def __schedule_series_scanner(self, sonarr_config): + self.__series_scanner_job(sonarr_config) + + if sonarr_config.series_scanner.hourly_job: + # Add a random delay of +-5 minutes between jobs + schedule.every(55).to(65).minutes.do( + self.__series_scanner_job, sonarr_config=sonarr_config + ) + + def __existing_renamer_job(self, sonarr_config): + try: + ExistingRenamer( + name=sonarr_config.name, + url=sonarr_config.url, + api_key=sonarr_config.api_key, + ).scan() + except CliServerError as exc: + logger.error(exc) + + def __schedule_existing_renamer(self, sonarr_config): + self.__existing_renamer_job(sonarr_config) + + if sonarr_config.existing_renamer.hourly_job: + # Add a random delay of +-5 minutes between jobs + schedule.every(55).to(65).minutes.do( + self.__existing_renamer_job, sonarr_config=sonarr_config + ) + + def start(self) -> None: + try: + config = configparser.get_config( + CONFIG_SCHEMA, + config_dir=path.relpath("/config"), + file_name="config.yml", + ) + except (ConfigError, ConfigFileNotFoundError) as exc: + logger.error(exc) + exit(1) + + for sonarr_config in config.sonarr: + if sonarr_config.series_scanner.enabled: + self.__schedule_series_scanner(sonarr_config) + if sonarr_config.existing_renamer.enabled: + self.__schedule_existing_renamer(sonarr_config) + + if schedule.get_jobs(): + while True: + schedule.run_pending() + sleep(1) if __name__ == "__main__": - loguru_config() - - try: - config = configparser.get_config( - CONFIG_SCHEMA, - config_dir=path.relpath("/config"), - file_name="config.yml", - ) - except (ConfigError, ConfigFileNotFoundError) as exc: - logger.error(exc) - exit(1) - - for sonarr_config in config.sonarr: - if sonarr_config.series_scanner.enabled: - schedule_series_scanner(sonarr_config) - if sonarr_config.existing_renamer.enabled: - schedule_existing_renamer(sonarr_config) - - if schedule.get_jobs(): - while True: - schedule.run_pending() - sleep(1) + Main().start() diff --git a/src/series_scanner.py b/src/series_scanner.py index baf43cc..db57f3b 100644 --- a/src/series_scanner.py +++ b/src/series_scanner.py @@ -6,18 +6,18 @@ class SeriesScanner: - def __init__(self, name, url, api_key, hours_before_air): + def __init__(self, name: str, url: str, api_key: str, hours_before_air: int): self.name = name self.sonarr_cli = SonarrCli(url, api_key) self.hours_before_air = min(hours_before_air, 12) - def scan(self): + def scan(self) -> None: with logger.contextualize(instance=self.name): logger.info("Starting Series Scan") series = self.sonarr_cli.get_serie() - if series is []: + if len(series) == 0: logger.error("Sonarr returned empty series list") else: logger.debug("Retrieved series list") @@ -27,7 +27,7 @@ def scan(self): if show.status.lower() == "continuing": episode_list = self.sonarr_cli.get_episode(show.id) - if episode_list is []: + if len(episode_list) == 0: logger.error("Error fetching episode list") continue else: @@ -54,7 +54,7 @@ def scan(self): break logger.debug("Finished Processing") - logger.info("Finished Series Scam") + logger.info("Finished Series Scan") # Filter episode list, so it only contains episodes with TBA title def __filter_episode_list(self, episode_list): @@ -88,7 +88,7 @@ def __is_episode_airing_soon(self, episode_air_date_utc): episode_air_date_utc - datetime.now(timezone.utc) ).total_seconds() / 3600 - return hours_till_airing <= self.hours_before_air + return 0 < hours_till_airing <= self.hours_before_air def __has_episode_already_aired(self, episode_air_date_utc): """ diff --git a/tests/fixtures/multiple_sonarrs.yml b/tests/fixtures/multiple_sonarrs.yml new file mode 100644 index 0000000..48de3e2 --- /dev/null +++ b/tests/fixtures/multiple_sonarrs.yml @@ -0,0 +1,21 @@ +sonarr: + - name: sonarr + url: https://sonarr.tld + api_key: sonarr-api-key + series_scanner: + enabled: False + hourly_job: False + hours_before_air: 2 + existing_renamer: + enabled: True + hourly_job: False + - name: sonarr1 + url: https://sonarr1.tld + api_key: sonarr1-api-key + series_scanner: + enabled: False + hourly_job: False + hours_before_air: 4 + existing_renamer: + enabled: False + hourly_job: False diff --git a/tests/fixtures/single_sonarr.yml b/tests/fixtures/single_sonarr.yml new file mode 100644 index 0000000..5aaf592 --- /dev/null +++ b/tests/fixtures/single_sonarr.yml @@ -0,0 +1,11 @@ +sonarr: + - name: sonarr + url: https://sonarr.tld + api_key: sonarr-api-key + series_scanner: + enabled: False + hourly_job: False + hours_before_air: 2 + existing_renamer: + enabled: False + hourly_job: False diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..aee7acd --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,55 @@ +import pytest +from config_schema import CONFIG_SCHEMA +from existing_renamer import ExistingRenamer +from main import Main +from pyconfigparser import Config, configparser +from series_scanner import SeriesScanner + +# disable config caching +configparser.hold_an_instance = False + + +class TestMain: + def get_single_config(self) -> Config: + return configparser.get_config( + CONFIG_SCHEMA, + config_dir="tests/fixtures", + file_name="single_sonarr.yml", + ) + + @pytest.fixture + def all_disabled(self, mocker): + mocker.patch( + "pyconfigparser.configparser.get_config" + ).return_value = self.get_single_config() + + @pytest.fixture + def series_scanner_enabled(self, mocker): + config = self.get_single_config() + config.sonarr[0].series_scanner.enabled = True + mocker.patch("pyconfigparser.configparser.get_config").return_value = config + + @pytest.fixture + def existing_renamer_enabled(self, mocker): + config = self.get_single_config() + config.sonarr[0].existing_renamer.enabled = True + mocker.patch("pyconfigparser.configparser.get_config").return_value = config + + def test_all_disabled(self, all_disabled, mocker): + series_scanner = mocker.patch.object(SeriesScanner, "scan") + existing_renamer = mocker.patch.object(ExistingRenamer, "scan") + + assert not series_scanner.called + assert not existing_renamer.called + + def test_series_scanner_scan(self, series_scanner_enabled, mocker) -> None: + series_scanner = mocker.patch.object(SeriesScanner, "scan") + + Main().start() + assert series_scanner.called + + def test_existing_renamer_scan(self, existing_renamer_enabled, mocker) -> None: + existing_renamer = mocker.patch.object(ExistingRenamer, "scan") + + Main().start() + assert existing_renamer.called diff --git a/tests/test_series_scanner.py b/tests/test_series_scanner.py new file mode 100644 index 0000000..6a3cd81 --- /dev/null +++ b/tests/test_series_scanner.py @@ -0,0 +1,149 @@ +import logging +from datetime import datetime, timedelta, timezone +from typing import List + +import pytest +from pycliarr.api import SonarrCli, SonarrSerieItem +from pycliarr.api.base_api import json_data +from series_scanner import SeriesScanner + + +class TestSeriesScanner: + @pytest.fixture + def get_serie(self, mocker) -> None: + series: List[SonarrSerieItem] = [ + SonarrSerieItem(id=1, title="test title", status="continuing") + ] + mocker.patch.object(SonarrCli, "get_serie").return_value = series + + def episode_data( + self, id: int, title: str, airDateDelta: timedelta, seasonNumber: str + ) -> dict: + return dict( + id=id, + title=title, + airDateUtc=(datetime.now(timezone.utc) + airDateDelta).isoformat(), + seasonNumber=seasonNumber, + ) + + def test_no_series_returned(self, caplog, mocker) -> None: + mocker.patch.object(SonarrCli, "get_serie").return_value = [] + with caplog.at_level(logging.DEBUG): + SeriesScanner("test", "test.tld", "test-api-key", 4).scan() + assert "Sonarr returned empty series list" in caplog.text + assert "Finished Series Scan" in caplog.text + + def test_when_series_returned_no_episodes(self, get_serie, caplog, mocker) -> None: + mocker.patch.object(SonarrCli, "get_episode").return_value = [] + with caplog.at_level(logging.DEBUG): + SeriesScanner("test", "test.tld", "test-api-key", 4).scan() + assert "Retrieved series list" in caplog.text + assert "Error fetching episode list" in caplog.text + + def test_when_show_status_not_continuuing(self, caplog, mocker) -> None: + series: List[SonarrSerieItem] = [ + SonarrSerieItem(id=1, title="test title", status="ended") + ] + mocker.patch.object(SonarrCli, "get_serie").return_value = series + get_episode = mocker.patch.object(SonarrCli, "get_episode") + get_episode.return_value = [] + + with caplog.at_level(logging.DEBUG): + SeriesScanner("test", "test.tld", "test-api-key", 4).scan() + # assert "Error fetching episode list" in caplog.text + + assert not get_episode.called + + def test_when_multiple_shows_continuing_and_ended(self, caplog, mocker) -> None: + series: List[SonarrSerieItem] = [ + SonarrSerieItem(id=1, title="title 1", status="continuing"), + SonarrSerieItem(id=2, title="title 2", status="ended"), + ] + mocker.patch.object(SonarrCli, "get_serie").return_value = series + + get_episode = mocker.patch.object(SonarrCli, "get_episode") + get_episode.return_value = [] + + with caplog.at_level(logging.DEBUG): + SeriesScanner("test", "test.tld", "test-api-key", 4).scan() + + get_episode.assert_called_once_with(1) + + def test_when_episodes_filtered_out(self, get_serie, caplog, mocker) -> None: + episodes: List[json_data] = [ + self.episode_data( + id=1, + title="TBA", + airDateDelta=timedelta(hours=8), + seasonNumber=1, + ), + self.episode_data( + id=2, + title="title", + airDateDelta=timedelta(hours=-2), + seasonNumber=1, + ), + self.episode_data( + id=3, + title="TBA", + airDateDelta=timedelta(hours=2), + seasonNumber=0, + ), + dict( + id=4, + title="TBA", + airDateUtc=None, + seasonNumber=1, + ), + ] + mocker.patch.object(SonarrCli, "get_episode").return_value = episodes + + refresh_serie = mocker.patch.object(SonarrCli, "refresh_serie") + + with caplog.at_level(logging.DEBUG): + SeriesScanner("test", "test.tld", "test-api-key", 4).scan() + + assert "Retrieved episode list" in caplog.text + assert not refresh_serie.called + + def test_when_tba_episode_is_airing_soon(self, get_serie, caplog, mocker) -> None: + episodes: List[json_data] = [ + self.episode_data( + id=1, + title="TBA", + airDateDelta=timedelta(hours=2), + seasonNumber=1, + ) + ] + mocker.patch.object(SonarrCli, "get_episode").return_value = episodes + + refresh_serie = mocker.patch.object(SonarrCli, "refresh_serie") + + with caplog.at_level(logging.DEBUG): + SeriesScanner("test", "test.tld", "test-api-key", 4).scan() + + assert refresh_serie.called + assert "Found TBA episode, airing within the next 4 hours" in caplog.text + assert "Series rescan triggered" in caplog.text + + def test_when_tba_episode_has_already_aired( + self, get_serie, caplog, mocker + ) -> None: + episodes: List[json_data] = [ + self.episode_data( + id=1, + title="TBA", + airDateDelta=timedelta(days=-1), + seasonNumber=1, + ) + ] + mocker.patch.object(SonarrCli, "get_episode").return_value = episodes + + refresh_serie = mocker.patch.object(SonarrCli, "refresh_serie") + + with caplog.at_level(logging.DEBUG): + SeriesScanner("test", "test.tld", "test-api-key", 4).scan() + + assert refresh_serie.called + assert "Found previously aired episode with TBA title" in caplog.text + assert "Series rescan triggered" in caplog.text