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