diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4347f54..8505326 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ on: - "develop" jobs: - ci: + unit_tests: runs-on: ubuntu-latest permissions: # Gives the action the necessary permissions for publishing new @@ -36,11 +36,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest coverage + pip install pytest pytest-mock coverage pip install -r requirements.txt - name: Test with pytest run: | - coverage run -m pytest + coverage run -m pytest -m "not integration" coverage report coverage xml - name: SonarCloud Scan @@ -48,4 +48,23 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - + integration_tests: + needs: unit_tests + runs-on: ubuntu-latest + steps: + # Purges github badge cache + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Python 3 + uses: actions/setup-python@v3 + with: + python-version: "3" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-mock + pip install -r requirements.txt + - name: Test with pytest + run: | + python -m pytest -m integration \ No newline at end of file diff --git a/.gitignore b/.gitignore index 868e66b..a68c0d3 100644 --- a/.gitignore +++ b/.gitignore @@ -190,4 +190,7 @@ pyrightconfig.json pyvenv.cfg pip-selfcheck.json -# End of https://www.toptal.com/developers/gitignore/api/python,virtualenv \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/python,virtualenv + +# Exception for app/deleterr/scripts +!/app/scripts/ \ No newline at end of file diff --git a/Makefile b/Makefile index 379ceaa..c4757e2 100644 --- a/Makefile +++ b/Makefile @@ -17,3 +17,13 @@ test: coverage run -m pytest coverage report coverage xml + +unit: + coverage run -m pytest -m "not integration" + coverage report + coverage xml + +integration: + coverage run -m pytest -m integration + coverage report + coverage xml \ No newline at end of file diff --git a/app/deleterr.py b/app/deleterr.py index ee1ca36..4ebbd84 100644 --- a/app/deleterr.py +++ b/app/deleterr.py @@ -99,11 +99,29 @@ def main(): default="/config/settings.yaml", help="Path to the config file", ) - args = parser.parse_args() + parser.add_argument( + "--jw-providers", action="store_true", help="Gather JustWatch providers" + ) + + args, unknown = parser.parse_known_args() config = load_config(args.config) config.validate() + # If providers flag is set, gather JustWatch providers and exit + if args.jw_providers: + from app.scripts.justwatch_providers import gather_providers + + providers = gather_providers( + config.settings.get("trakt", {}).get("client_id"), + config.settings.get("trakt", {}).get("client_secret"), + ) + + print(providers) + logger.info("# of Trakt Providers: " + str(len(providers))) + + return + Deleterr(config) diff --git a/app/modules/justwatch.py b/app/modules/justwatch.py new file mode 100644 index 0000000..03f319d --- /dev/null +++ b/app/modules/justwatch.py @@ -0,0 +1,51 @@ +from simplejustwatchapi.justwatch import search + +from app import logger + + +class JustWatch: + def __init__(self, country, language): + self.country = country + self.language = language + logger.debug( + "JustWatch instance created with country: %s and language: %s", + country, + language, + ) + + """ + Search for a title on JustWatch API + Returns: + [MediaEntry(entry_id='ts8', object_id=8, object_type='SHOW', title='Better Call Saul', url='https://justwatch.com/pt/serie/better-call-saul', release_year=2015, release_date='2015-02-08', runtime_minutes=50, short_description='Six years before Saul Goodman meets Walter White. We meet him when the man who will become Saul Goodman is known as Jimmy McGill, a small-time lawyer searching for his destiny, and, more immediately, hustling to make ends meet. Working alongside, and, often, against Jimmy, is “fixer” Mike Ehrmantraut. The series tracks Jimmy’s transformation into Saul Goodman, the man who puts “criminal” in “criminal lawyer".', genres=['crm', 'drm'], imdb_id='tt3032476', poster='https://images.justwatch.com/poster/269897858/s718/better-call-saul.jpg', backdrops=['https://images.justwatch.com/backdrop/171468199/s1920/better-call-saul.jpg', 'https://images.justwatch.com/backdrop/269897860/s1920/better-call-saul.jpg', 'https://images.justwatch.com/backdrop/302946702/s1920/better-call-saul.jpg', 'https://images.justwatch.com/backdrop/304447863/s1920/better-call-saul.jpg', 'https://images.justwatch.com/backdrop/273394969/s1920/better-call-saul.jpg'], offers=[Offer(id='b2Z8dHM4OlBUOjg6ZmxhdHJhdGU6NGs=', monetization_type='FLATRATE', presentation_type='_4K', price_string=None, price_value=None, price_currency='EUR', last_change_retail_price_value=None, type='AGGREGATED', package=OfferPackage(id='cGF8OA==', package_id=8, name='Netflix', technical_name='netflix', icon='https://images.justwatch.com/icon/207360008/s100/netflix.png'), url='http://www.netflix.com/title/80021955', element_count=6, available_to=None, deeplink_roku='launch/12?contentID=80021955&MediaType=show', subtitle_languages=[], video_technology=[], audio_technology=[], audio_languages=[])])] + """ + + def _search(self, title, max_results=5, detailed=False): + return search(title, self.country, self.language, max_results, detailed) + + def search_by_title_and_year(self, title, year, media_type): + results = self._search(title) + for entry in results: + if entry.title == title and entry.release_year == year: + return entry + return None + + def available_on(self, title, year, media_type, providers): + result = self.search_by_title_and_year(title, year, media_type) + if not result: + logger.debug("No results found for title: {title}") + return False + + if "any" in providers and result.offers: + logger.debug("Title {title} available on any provider") + return True + + for provider in providers: + for offer in result.offers: + if offer.package.technical_name == provider.lower(): + logger.debug("Title {title} available on {provider}") + return True + + return False + + def is_not_available_on(self, title, year, media_type, providers): + return not self.available_on(title, year, media_type, providers) diff --git a/app/modules/tautulli.py b/app/modules/tautulli.py index 40dc531..68ee0b3 100644 --- a/app/modules/tautulli.py +++ b/app/modules/tautulli.py @@ -34,9 +34,6 @@ def __init__(self, url, api_key): def test_connection(self): self.api.status() - def get_last_episode_activity(self, library_config, section): - return self.get_activity(library_config, section) - def refresh_library(self, section_id): self.api.get_library_media_info(section_id=section_id, refresh=True) diff --git a/app/scripts/__init__.py b/app/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/scripts/justwatch_providers.py b/app/scripts/justwatch_providers.py new file mode 100644 index 0000000..6a914e6 --- /dev/null +++ b/app/scripts/justwatch_providers.py @@ -0,0 +1,70 @@ +from app.modules.justwatch import JustWatch +from app.modules.trakt import Trakt + + +def gather_providers(trakt_id, trakt_secret): + # Create a Trakt instance + trakt = Trakt( + trakt_id, + trakt_secret, + ) + + # Get the most popular shows + shows = trakt.get_all_items_for_url( + "show", + { + "max_items_per_list": 200, + "lists": [ + "https://trakt.tv/shows/trending", + "https://trakt.tv/shows/popular", + "https://trakt.tv/shows/watched/yearly", + "https://trakt.tv/shows/collected/yearly", + ], + }, + ) + + # List of country codes to check providers for + countries = [ + "US", + "BR", + "NG", + "IN", + "CN", + "RU", + "AU", + "PT", + "FR", + "DE", + "ES", + "IT", + "JP", + "KR", + "GB", + ] + + # Create a set to store the providers + providers = set() + + # Iterate over the countries + for country in countries: + # Create a JustWatch instance for the current country + justwatch = JustWatch(country, "en") + + # Iterate shows and collect all the different providers in a set + for show in shows: + try: + title = shows[show]["trakt"].title + year = shows[show]["trakt"].year + result = justwatch.search_by_title_and_year(title, year, "show") + if result: + for offer in result.offers: + providers.add(offer.package.technical_name) + except AttributeError: + # Skip if the show doesn't have a title or year + continue + except TypeError: + # There is a null error inside justwatch library, this is a workaround + continue + + # Print the providers + return providers diff --git a/requirements.txt b/requirements.txt index 214a51b..7322e90 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,4 @@ PyYAML==6.0.1 requests==2.31.0 tautulli==3.7.0.2120 trakt.py==4.4.0 -pytest-mock \ No newline at end of file +simple-justwatch-python-api==0.14 \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties index bb6032e..bd84d93 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -8,7 +8,7 @@ sonar.python.version=3 # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. sonar.sources=app/ -sonar.tests.exclusions=tests/** +sonar.exclusions=/app/scripts/** sonar.tests=tests/ sonar.language=python diff --git a/tests/modules/test_justwatch.py b/tests/modules/test_justwatch.py new file mode 100644 index 0000000..6b19c27 --- /dev/null +++ b/tests/modules/test_justwatch.py @@ -0,0 +1,225 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from app.modules.justwatch import JustWatch + + +@pytest.mark.unit +class TestJustWatch: + @patch("app.modules.justwatch.search") + def test_search(self, mock_search): + # Arrange + mock_search.return_value = ["result1", "result2", "result3"] + justwatch_instance = JustWatch("US", "en") + + # Act + result = justwatch_instance._search("test_title") + + # Assert + mock_search.assert_called_once_with("test_title", "US", "en", 5, False) + assert result == ["result1", "result2", "result3"] + + @patch.object(JustWatch, "_search") + def test_search_by_title_and_year(self, mock_search): + # Arrange + mock_entry1 = MagicMock() + mock_entry1.title = "title1" + mock_entry1.release_year = 2001 + + mock_entry2 = MagicMock() + mock_entry2.title = "title2" + mock_entry2.release_year = 2002 + + mock_entry3 = MagicMock() + mock_entry3.title = "title1" + mock_entry3.release_year = 2002 + + mock_search.return_value = [mock_entry1, mock_entry2, mock_entry3] + + justwatch_instance = JustWatch("US", "en") + + # Act + result = justwatch_instance.search_by_title_and_year("title1", 2001, "movie") + + # Assert + mock_search.assert_called_once_with("title1") + assert result == mock_entry1 + + @patch.object(JustWatch, "_search") + def test_search_by_title_and_year(self, mock_search): + # Arrange + mock_entry1 = MagicMock() + mock_entry1.title = "title1" + mock_entry1.release_year = 2001 + + mock_entry2 = MagicMock() + mock_entry2.title = "title2" + mock_entry2.release_year = 2002 + + mock_entry3 = MagicMock() + mock_entry3.title = "title1" + mock_entry3.release_year = 2001 + + mock_search.return_value = [mock_entry1, mock_entry2, mock_entry3] + + justwatch_instance = JustWatch("US", "en") + + # Act + result = justwatch_instance.search_by_title_and_year("title1", 2001, "movie") + + # Assert + mock_search.assert_called_once_with("title1") + assert result == mock_entry1 + + @patch.object(JustWatch, "search_by_title_and_year") + def test_available_on(self, mock_search_by_title_and_year): + # Arrange + mock_offer = MagicMock() + mock_offer.package.technical_name = "provider1" + + mock_entry1 = MagicMock() + mock_entry1.offers = [mock_offer] + + mock_search_by_title_and_year.return_value = mock_entry1 + justwatch_instance = JustWatch("US", "en") + + # Act + result = justwatch_instance.available_on("title1", 2001, "movie", ["provider1"]) + + # Assert + mock_search_by_title_and_year.assert_called_once_with("title1", 2001, "movie") + assert result + + @patch.object(JustWatch, "search_by_title_and_year") + def test_available_on_false(self, mock_search_by_title_and_year): + # Arrange + mock_offer = MagicMock() + mock_offer.package.technical_name = "provider2" + + mock_entry1 = MagicMock() + mock_entry1.offers = [mock_offer] + + mock_search_by_title_and_year.return_value = mock_entry1 + justwatch_instance = JustWatch("US", "en") + + # Act + result = justwatch_instance.available_on("title1", 2001, "movie", ["provider1"]) + + # Assert + mock_search_by_title_and_year.assert_called_once_with("title1", 2001, "movie") + assert not result + + @patch.object(JustWatch, "search_by_title_and_year") + def test_available_on_any(self, mock_search_by_title_and_year): + # Arrange + mock_entry1 = MagicMock() + mock_entry1.offers = ["provider1", "provider2"] + + mock_search_by_title_and_year.return_value = mock_entry1 + justwatch_instance = JustWatch("US", "en") + + # Act + result = justwatch_instance.available_on("title1", 2001, "movie", ["any"]) + + # Assert + mock_search_by_title_and_year.assert_called_once_with("title1", 2001, "movie") + assert result + + @patch.object(JustWatch, "search_by_title_and_year") + def test_available_on_no_result(self, mock_search_by_title_and_year): + # Arrange + mock_search_by_title_and_year.return_value = None + justwatch_instance = JustWatch("US", "en") + + # Act + result = justwatch_instance.available_on("title1", 2001, "movie", ["provider1"]) + + # Assert + mock_search_by_title_and_year.assert_called_once_with("title1", 2001, "movie") + assert not result + + @patch.object(JustWatch, "available_on") + def test_is_not_available_on_true(self, mock_available_on): + # Arrange + mock_available_on.return_value = False + justwatch_instance = JustWatch("US", "en") + + # Act + result = justwatch_instance.is_not_available_on( + "title1", 2001, "movie", ["provider1"] + ) + + # Assert + mock_available_on.assert_called_once_with( + "title1", 2001, "movie", ["provider1"] + ) + assert result + + @patch.object(JustWatch, "available_on") + def test_is_not_available_on_false(self, mock_available_on): + # Arrange + mock_available_on.return_value = True + justwatch_instance = JustWatch("US", "en") + + # Act + result = justwatch_instance.is_not_available_on( + "title1", 2001, "movie", ["provider1"] + ) + + # Assert + mock_available_on.assert_called_once_with( + "title1", 2001, "movie", ["provider1"] + ) + assert not result + + +@pytest.mark.integration +class TestJustWatchIntegration: + def test_search(self): + # Arrange + justwatch_instance = JustWatch("US", "en") + + # Act + result = justwatch_instance._search("Better Call Saul") + + # Assert + assert isinstance(result, list) + + assert result + + def test_search_by_title_and_year(self): + # Arrange + justwatch_instance = JustWatch("US", "en") + + # Act + result = justwatch_instance.search_by_title_and_year( + "Better Call Saul", 2015, "show" + ) + + # Assert that the object exists + assert result + + def test_available_on(self): + # Arrange + justwatch_instance = JustWatch("US", "en") + + # Act + result = justwatch_instance.available_on( + "Better Call Saul", 2015, "show", ["Netflix"] + ) + + # Assert + assert result + + def test_is_not_available_on(self): + # Arrange + justwatch_instance = JustWatch("US", "en") + + # Act + result = justwatch_instance.is_not_available_on( + "Better Call Saul", 2015, "show", ["Disney+"] + ) + + # Assert + assert result diff --git a/tox.ini b/tox.ini index c52b511..3300288 100644 --- a/tox.ini +++ b/tox.ini @@ -2,4 +2,9 @@ max-line-length = 200 extend-ignore = E203 exclude = tests/* -max-complexity = 10 \ No newline at end of file +max-complexity = 10 + +[pytest] +markers = + unit: marks tests as unit tests + integration: marks tests as integration tests \ No newline at end of file