diff --git a/.gitignore b/.gitignore index 45b27b2..b57a0c5 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ __pycache__/ # Pyenv **/.python-version +# Venv +venv/ + # PyInstaller build/ dist/ diff --git a/README.md b/README.md index 94f5e0f..1c58e2e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ +[![made-with-python](https://img.shields.io/badge/Made%20with-Python-blue.svg)](https://www.python.org/) +[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://github.com/l3uddz/traktarr/blob/master/LICENSE) +[![Feature Requests](https://img.shields.io/badge/Requests-Feathub-blue.svg)](http://feathub.com/l3uddz/traktarr) +[![Discord](https://img.shields.io/discord/381077432285003776.svg)](https://discord.gg/xmNYmSJ) + + # traktarr -Add new shows & movies to Sonarr/Radarr from Trakt. + +**traktarr** uses Trakt to add new shows into Sonarr and new movies into Radarr. Types of Trakt lists supported: @@ -11,7 +18,7 @@ Types of Trakt lists supported: - Anticipated - - boxoffice + - Boxoffice - Public lists @@ -23,6 +30,53 @@ Types of Trakt lists supported: \* Support for multiple (authenticated) users. +--- + + + +- [traktarr](#traktarr) +- [Demo](#demo) +- [Requirements](#requirements) +- [Installation](#installation) + - [1. Base Install](#1-base-install) + - [2. Create a Trakt Application](#2-create-a-trakt-application) + - [3. Authenticate User(s) (optional)](#3-authenticate-users-optional) +- [Configuration](#configuration) + - [Sample Configuration](#sample-configuration) + - [Core](#core) + - [Automatic](#automatic) + - [Personal Watchlists](#personal-watchlists) + - [Custom Lists](#custom-lists) + - [Public Lists](#public-lists) + - [Private Lists](#private-lists) + - [Filters](#filters) + - [Movies](#movies) + - [Shows](#shows) + - [Notifications](#notifications) + - [Pushover](#pushover) + - [Slack](#slack) + - [Radarr](#radarr) + - [Sonarr](#sonarr) + - [Tags](#tags) + - [Trakt](#trakt) +- [Usage](#usage) + - [Automatic (Scheduled)](#automatic-scheduled) + - [Setup](#setup) + - [Customize](#customize) + - [Manual (CLI)](#manual-cli) + - [General](#general) + - [Movie (Single Movie)](#movie-single-movie) + - [Movies (Multiple Movies)](#movies-multiple-movies) + - [Show (Single Show)](#show-single-show) + - [Shows (Multiple Shows)](#shows-multiple-shows) + - [Examples (Manual)](#examples-manual) + + + +--- + + + # Demo @@ -33,8 +87,11 @@ Click to enlarge. # Requirements -1. Python 3.5 or higher (`sudo apt install python3 python3-pip`). -2. requirements.txt modules (see below). +1. Ubuntu/Debian + +2. Python 3.5 or higher (`sudo apt install python3 python3-pip`). + +3. requirements.txt modules (see below). # Installation @@ -43,20 +100,31 @@ Click to enlarge. Install traktarr to be run with `traktarr` command. 1. `cd /opt` + 2. `sudo git clone https://github.com/l3uddz/traktarr` + 3. `sudo chown -R user:group traktarr` (run `id` to find your user / group) + 4. `cd traktarr` + 5. `sudo python3 -m pip install -r requirements.txt` + 6. `sudo ln -s /opt/traktarr/traktarr.py /usr/local/bin/traktarr` + 7. `traktarr` - run once to generate a sample a config.json file. + 8. `nano config.json` - edit preferences. ## 2. Create a Trakt Application 1. Create a Trakt application by going [here](https://trakt.tv/oauth/applications/new) + 2. Enter a name for your application; for example `traktarr` + 3. Enter `urn:ietf:wg:oauth:2.0:oob` in the `Redirect uri` field. + 4. Click "SAVE APP". + 5. Open the traktarr configuration file `config.json` and insert the Client ID in the `client_id` and the Client Secret in the `client_secret`, like this: ``` @@ -73,7 +141,9 @@ Install traktarr to be run with `traktarr` command. For each user you want to access the private lists for (i.e. watchlist and/or custom lists), you will need to to authenticate that user. Repeat the following steps for every user you want to authenticate: + 1. Run `traktarr trakt_authentication` + 2. You wil get the following prompt: ``` @@ -81,13 +151,19 @@ Repeat the following steps for every user you want to authenticate: - Go to: https://trakt.tv/activate on any device and enter A0XXXXXX. We'll be polling Trakt every 5 seconds for a reply ``` 3. Go to https://trakt.tv/activate. + 4. Enter the code you see in your terminal. + 5. Click continue. + 6. If you are not logged in to Trakt, login now. + 7. Click "Accept". + 8. You will get the message: "Woohoo! Your device is now connected and will automatically refresh in a few seconds.". You've now authenticated the user. + You can repeat this process for as many users as you like. @@ -122,6 +198,7 @@ You can repeat this process for as many users as you like. "gb", "ca" ], + "allowed_languages": [], "blacklist_title_keywords": [ "untitled", "barbie", @@ -143,6 +220,7 @@ You can repeat this process for as many users as you like. "gb", "ca" ], + "allowed_languages": [], "blacklisted_genres": [ "animation", "game-show", @@ -317,45 +395,45 @@ Private lists can be added in two ways: 1. If there is only one authenticated user, you can add the private list just like any other public list: -```json -"automatic": { - "movies": { - "lists": { - "https://trakt.tv/users/user/lists/my-private-movies-list": 10 - } - }, - "shows": { - "lists": { - "https://trakt.tv/users/user/lists/my-private-shows-list": 10 - } - } -}, -``` + ```json + "automatic": { + "movies": { + "lists": { + "https://trakt.tv/users/user/lists/my-private-movies-list": 10 + } + }, + "shows": { + "lists": { + "https://trakt.tv/users/user/lists/my-private-shows-list": 10 + } + } + }, + ``` 2. If there are multiple authenticated users you want to fetch the lists from, you'll need to specify the username under `authenticate_as`. -_Note: The user should have access to the list (either own the list or a list that was shared to them by a friend)._ + _Note: The user should have access to the list (either own the list or a list that was shared to them by a friend)._ -```json -"automatic": { - "movies": { - "lists": { - "https://trakt.tv/users/user/lists/my-private-movies-list": { - "authenticate_as": "user2", - "limit": 10 - } - } - }, - "shows": { - "lists": { - "https://trakt.tv/users/user/lists/my-private-shows-list": { - "authenticate_as": "user2", - "limit": 10 - } - } - } -}, -``` + ```json + "automatic": { + "movies": { + "lists": { + "https://trakt.tv/users/user/lists/my-private-movies-list": { + "authenticate_as": "user2", + "limit": 10 + } + } + }, + "shows": { + "lists": { + "https://trakt.tv/users/user/lists/my-private-shows-list": { + "authenticate_as": "user2", + "limit": 10 + } + } + } + }, + ``` ## Filters @@ -371,6 +449,7 @@ Use filters to specify the movie/shows's country of origin or blacklist (i.e. fi "gb", "ca" ], + "allowed_languages": [], "blacklist_title_keywords": [ "untitled", "barbie" @@ -387,7 +466,12 @@ Use filters to specify the movie/shows's country of origin or blacklist (i.e. fi }, ``` -`allowed_countries` - allowed countries of origin. +`allowed_countries` - only add movies from these countries. + +`allowed_languages` - only add movies with these languages (default/blank=English). + +- By default, traktarr will only query shows in English. If you need to search for other languages (e.g. Japanese for anime), you must add those languages here. +- Languages are in [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) format (e.g. `ja` for Japanese.) `blacklist_title_keywords` - blacklist certain words in titles. @@ -410,6 +494,7 @@ Use filters to specify the movie/shows's country of origin or blacklist (i.e. fi "gb", "ca" ], + "allowed_languages": [], "blacklisted_genres": [ "animation", "game-show", @@ -445,7 +530,12 @@ Use filters to specify the movie/shows's country of origin or blacklist (i.e. fi } ``` -`allowed_countries` - allowed countries of origin. +`allowed_countries` - only add shows from these countries. + +`allowed_languages` - only add shows with these languages (default/blank=English). + +- By default, traktarr will only query shows in English. If you need to search for other languages (e.g. Japanese for anime), you must add those languages here. +- Languages are in [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) format (e.g. `ja` for Japanese.) `blacklisted_genres` - blacklist certain genres. @@ -551,49 +641,46 @@ Sonarr configuration. ### Tags -To show how tags work, we will create a sample tag `AMZN` and assign it to certain networks. - -_Note: These are optional._ - -### Sonarr +The `tags` option allows Sonarr to assign tags to shows from specific television networks, so that Sonarr can filter in/out certain keywords from releases. -First, we will create a tag in Sonarr (Settings > Indexers > Restrictions). - -``` -Must contain: BluRay, Amazon, AMZN, -Must not contain: -Tags: AMZN -``` +**Example:** -### traktarr +To show how tags work, we will create a tag `AMZN` and assign it to certain television networks that usually have AMZN releases. -Finally, we will edit the traktarr config and assign the `AMZN` tag to certain networks. +1. First, we will create a tag in Sonarr (Settings > Indexers > Restrictions). -```json -"tags": { - "amzn": [ - "hbo", - "amc", - "usa network", - "tnt", - "starz", - "the cw", - "fx", - "fox", - "abc", - "nbc", - "cbs", - "tbs", - "amazon", - "syfy", - "cinemax", - "bravo", - "showtime", - "paramount network" - ] -} + ``` + Must contain: BluRay, Amazon, AMZN + Must not contain: + Tags: AMZN + ``` -``` +2. And, finally, we will edit the traktarr config and assign the `AMZN` tag to some networks. + + ```json + "tags": { + "amzn": [ + "hbo", + "amc", + "usa network", + "tnt", + "starz", + "the cw", + "fx", + "fox", + "abc", + "nbc", + "cbs", + "tbs", + "amazon", + "syfy", + "cinemax", + "bravo", + "showtime", + "paramount network" + ] + } + ``` ## Trakt @@ -615,14 +702,41 @@ Trakt Authentication info: ## Automatic (Scheduled) -To have traktarr get Movies and Shows for you automatically, on set interval. +### Setup + +To have traktarr get Movies and Shows for you automatically, on set interval, do the following: 1. `sudo cp /opt/traktarr/systemd/traktarr.service /etc/systemd/system/` + 2. `sudo nano /etc/systemd/system/traktarr.service` and edit user/group to match yours. + 3. `sudo systemctl daemon-reload` + 4. `sudo systemctl enable traktarr.service` + 5. `sudo systemctl start traktarr.service` +### Customize + +You can customize how the scheduled traktarr is ran by editing the `traktarr.service` file and adding any of the following options: + +``` + -d, --add-delay FLOAT Seconds between each add request to Sonarr / Radarr. + [default: 2.5] + --no-search Disable search when adding to Sonarr / Radarr. + --run-now Do a first run immediately without waiting. + --no-notifications Disable notifications. + --help Show this message and exit. +``` + +You can bring up the list, anytime, by running the following command: + +``` +traktarr run --help +``` + +\* Remember, other configuration options need to go into the `config.json` file under the `Automatic` section. + ## Manual (CLI) ### General @@ -669,7 +783,7 @@ Options: --help Show this message and exit. ``` -_Note: This command only works with `-id` or `--show_id` specified (i.e. not with lists), and support both Trakt IDs and IMDB IDs._ +_Note: This command only works with `-id` or `--show_id` specified (i.e. not with lists), and supports both Trakt IDs and IMDB IDs._ ### Movies (Multiple Movies) @@ -720,7 +834,7 @@ Options: --help Show this message and exit. ``` -_Note: This command only works with `-id` or `--show_id` specified (i.e. not with lists), and support both Trakt IDs and IMDB IDs._ +_Note: This command only works with `-id` or `--show_id` specified (i.e. not with lists), and supports both Trakt IDs and IMDB IDs._ ### Shows (Multiple Shows) diff --git a/helpers/misc.py b/helpers/misc.py new file mode 100644 index 0000000..0c6572b --- /dev/null +++ b/helpers/misc.py @@ -0,0 +1,65 @@ +from misc.log import logger + +log = logger.get_logger(__name__) + + +def get_response_dict(response, key_field=None, key_value=None): + found_response = None + try: + if isinstance(response, list): + if not key_field or not key_value: + found_response = response[0] + else: + for result in response: + if isinstance(result, dict) and key_field in result and result[key_field] == key_value: + found_response = result + break + + if not found_response: + log.error("Unable to find a result with key %s where the value is %s", key_field, key_value) + + elif isinstance(response, dict): + found_response = response + else: + log.error("Unexpected response instance type of %s for %s", type(response).__name__, response) + + except Exception: + log.exception("Exception determining response for %s: ", response) + return found_response + + +def backoff_handler(details): + log.warning("Backing off {wait:0.1f} seconds afters {tries} tries " + "calling function {target} with args {args} and kwargs " + "{kwargs}".format(**details)) + + +def dict_merge(dct, merge_dct): + for k, v in merge_dct.items(): + import collections + + if k in dct and isinstance(dct[k], dict) and isinstance(merge_dct[k], collections.Mapping): + dict_merge(dct[k], merge_dct[k]) + else: + dct[k] = merge_dct[k] + + return dct + + +def unblacklist_genres(genre, blacklisted_genres): + genres = genre.split(',') + for allow_genre in genres: + if allow_genre in blacklisted_genres: + blacklisted_genres.remove(allow_genre) + return + + +def allowed_genres(genre, object_type, trakt_object): + allowed_object = False + genres = genre.split(',') + + for item in genres: + if item.lower() in trakt_object[object_type]['genres']: + allowed_object = True + break + return allowed_object diff --git a/helpers/radarr.py b/helpers/radarr.py new file mode 100644 index 0000000..c8732f2 --- /dev/null +++ b/helpers/radarr.py @@ -0,0 +1,50 @@ +from misc.log import logger + +log = logger.get_logger(__name__) + + +def movies_to_tmdb_dict(radarr_movies): + movies = {} + try: + for tmp in radarr_movies: + if 'tmdbId' not in tmp: + log.debug("Could not handle movie: %s", tmp['title']) + continue + movies[tmp['tmdbId']] = tmp + return movies + except Exception: + log.exception("Exception processing Radarr movies to TMDB dict: ") + return None + + +def remove_existing_movies(radarr_movies, trakt_movies): + new_movies_list = [] + + if not radarr_movies or not trakt_movies: + log.error("Inappropriate parameters were supplied") + return None + + try: + # turn radarr movies result into a dict with tmdb id as keys + processed_movies = movies_to_tmdb_dict(radarr_movies) + if not processed_movies: + return None + + # loop list adding to movies that do not already exist + for tmp in trakt_movies: + if 'movie' not in tmp or 'ids' not in tmp['movie'] or 'tmdb' not in tmp['movie']['ids']: + log.debug("Skipping movie because it did not have required fields: %s", tmp) + continue + # check if movie exists in processed_movies + if tmp['movie']['ids']['tmdb'] in processed_movies: + log.debug("Removing existing movie: %s", tmp['movie']['title']) + continue + + new_movies_list.append(tmp) + + log.debug("Filtered %d Trakt movies to %d movies that weren't already in Radarr", len(trakt_movies), + len(new_movies_list)) + return new_movies_list + except Exception: + log.exception("Exception removing existing movies from Trakt list: ") + return None diff --git a/helpers/sonarr.py b/helpers/sonarr.py new file mode 100644 index 0000000..52c2f91 --- /dev/null +++ b/helpers/sonarr.py @@ -0,0 +1,81 @@ +from misc.log import logger + +log = logger.get_logger(__name__) + + +def series_tag_id_from_network(profile_tags, network_tags, network): + try: + tags = [] + for tag_name, tag_networks in network_tags.items(): + for tag_network in tag_networks: + if tag_network.lower() in network.lower() and tag_name.lower() in profile_tags: + log.debug("Using %s tag for network: %s", tag_name, network) + tags.append(profile_tags[tag_name.lower()]) + if tags: + return tags + except Exception: + log.exception("Exception determining tag to use for network %s: ", network) + return None + + +def readable_tag_from_ids(profile_tag_ids, chosen_tag_ids): + try: + if not chosen_tag_ids: + return None + + tags = [] + for tag_name, tag_id in profile_tag_ids.items(): + if tag_id in chosen_tag_ids: + tags.append(tag_name) + if tags: + return tags + except Exception: + log.exception("Exception building readable tag name list from ids %s: ", chosen_tag_ids) + return None + + +def series_to_tvdb_dict(sonarr_series): + series = {} + try: + for tmp in sonarr_series: + if 'tvdbId' not in tmp: + log.debug("Could not handle show: %s", tmp['title']) + continue + series[tmp['tvdbId']] = tmp + return series + except Exception: + log.exception("Exception processing Sonarr shows to TVDB dict: ") + return None + + +def remove_existing_series(sonarr_series, trakt_series): + new_series_list = [] + + if not sonarr_series or not trakt_series: + log.error("Inappropriate parameters were supplied") + return None + + try: + # turn sonarr series result into a dict with tvdb id as keys + processed_series = series_to_tvdb_dict(sonarr_series) + if not processed_series: + return None + + # loop list adding to series that do not already exist + for tmp in trakt_series: + if 'show' not in tmp or 'ids' not in tmp['show'] or 'tvdb' not in tmp['show']['ids']: + log.debug("Skipping show because it did not have required fields: %s", tmp) + continue + # check if show exists in processed_series + if tmp['show']['ids']['tvdb'] in processed_series: + log.debug("Removing existing show: %s", tmp['show']['title']) + continue + + new_series_list.append(tmp) + + log.debug("Filtered %d Trakt shows to %d shows that weren't already in Sonarr", len(trakt_series), + len(new_series_list)) + return new_series_list + except Exception: + log.exception("Exception removing existing shows from Trakt list: ") + return None diff --git a/misc/str.py b/helpers/str.py similarity index 100% rename from misc/str.py rename to helpers/str.py diff --git a/misc/helpers.py b/helpers/trakt.py similarity index 51% rename from misc/helpers.py rename to helpers/trakt.py index 60ad23e..6e65ccb 100644 --- a/misc/helpers.py +++ b/helpers/trakt.py @@ -1,92 +1,10 @@ -from misc import str as misc_str +from helpers import str as misc_str from misc.log import logger log = logger.get_logger(__name__) -############################################################ -# SONARR -############################################################ - -def sonarr_series_tag_id_from_network(profile_tags, network_tags, network): - try: - tags = [] - for tag_name, tag_networks in network_tags.items(): - for tag_network in tag_networks: - if tag_network.lower() in network.lower() and tag_name.lower() in profile_tags: - log.debug("Using %s tag for network: %s", tag_name, network) - tags.append(profile_tags[tag_name.lower()]) - if tags: - return tags - except Exception: - log.exception("Exception determining tag to use for network %s: ", network) - return None - - -def sonarr_readable_tag_from_ids(profile_tag_ids, chosen_tag_ids): - try: - if not chosen_tag_ids: - return None - - tags = [] - for tag_name, tag_id in profile_tag_ids.items(): - if tag_id in chosen_tag_ids: - tags.append(tag_name) - if tags: - return tags - except Exception: - log.exception("Exception building readable tag name list from ids %s: ", chosen_tag_ids) - return None - - -def sonarr_series_to_tvdb_dict(sonarr_series): - series = {} - try: - for tmp in sonarr_series: - if 'tvdbId' not in tmp: - log.debug("Could not handle show: %s", tmp['title']) - continue - series[tmp['tvdbId']] = tmp - return series - except Exception: - log.exception("Exception processing Sonarr shows to TVDB dict: ") - return None - - -def sonarr_remove_existing_series(sonarr_series, trakt_series): - new_series_list = [] - - if not sonarr_series or not trakt_series: - log.error("Inappropriate parameters were supplied") - return None - - try: - # turn sonarr series result into a dict with tvdb id as keys - processed_series = sonarr_series_to_tvdb_dict(sonarr_series) - if not processed_series: - return None - - # loop list adding to series that do not already exist - for tmp in trakt_series: - if 'show' not in tmp or 'ids' not in tmp['show'] or 'tvdb' not in tmp['show']['ids']: - log.debug("Skipping show because it did not have required fields: %s", tmp) - continue - # check if show exists in processed_series - if tmp['show']['ids']['tvdb'] in processed_series: - log.debug("Removing existing show: %s", tmp['show']['title']) - continue - - new_series_list.append(tmp) - - log.debug("Filtered %d Trakt shows to %d shows that weren't already in Sonarr", len(trakt_series), - len(new_series_list)) - return new_series_list - except Exception: - log.exception("Exception removing existing shows from Trakt list: ") - return None - - -def trakt_blacklisted_show_genre(show, genres): +def blacklisted_show_genre(show, genres): blacklisted = False try: if not show['show']['genres']: @@ -104,7 +22,7 @@ def trakt_blacklisted_show_genre(show, genres): return blacklisted -def trakt_blacklisted_show_year(show, earliest_year, latest_year): +def blacklisted_show_year(show, earliest_year, latest_year): blacklisted = False try: year = misc_str.get_year_from_timestamp(show['show']['first_aired']) @@ -120,7 +38,7 @@ def trakt_blacklisted_show_year(show, earliest_year, latest_year): return blacklisted -def trakt_blacklisted_show_country(show, allowed_countries): +def blacklisted_show_country(show, allowed_countries): blacklisted = False try: if not show['show']['country']: @@ -137,7 +55,7 @@ def trakt_blacklisted_show_country(show, allowed_countries): return blacklisted -def trakt_blacklisted_show_network(show, networks): +def blacklisted_show_network(show, networks): blacklisted = False try: if not show['show']['network']: @@ -156,7 +74,7 @@ def trakt_blacklisted_show_network(show, networks): return blacklisted -def trakt_blacklisted_show_runtime(show, lowest_runtime): +def blacklisted_show_runtime(show, lowest_runtime): blacklisted = False try: if not show['show']['runtime'] or not isinstance(show['show']['runtime'], int): @@ -172,7 +90,7 @@ def trakt_blacklisted_show_runtime(show, lowest_runtime): return blacklisted -def trakt_blacklisted_show_id(show, blacklisted_ids): +def blacklisted_show_id(show, blacklisted_ids): blacklisted = False try: if not show['show']['ids']['tvdb'] or not isinstance(show['show']['ids']['tvdb'], int): @@ -188,79 +106,28 @@ def trakt_blacklisted_show_id(show, blacklisted_ids): return blacklisted -def trakt_is_show_blacklisted(show, blacklist_settings): +def is_show_blacklisted(show, blacklist_settings): blacklisted = False try: - if trakt_blacklisted_show_year(show, blacklist_settings.blacklisted_min_year, - blacklist_settings.blacklisted_max_year): + if blacklisted_show_year(show, blacklist_settings.blacklisted_min_year, + blacklist_settings.blacklisted_max_year): blacklisted = True - if trakt_blacklisted_show_country(show, blacklist_settings.allowed_countries): + if blacklisted_show_country(show, blacklist_settings.allowed_countries): blacklisted = True - if trakt_blacklisted_show_genre(show, blacklist_settings.blacklisted_genres): + if blacklisted_show_genre(show, blacklist_settings.blacklisted_genres): blacklisted = True - if trakt_blacklisted_show_network(show, blacklist_settings.blacklisted_networks): + if blacklisted_show_network(show, blacklist_settings.blacklisted_networks): blacklisted = True - if trakt_blacklisted_show_runtime(show, blacklist_settings.blacklisted_min_runtime): + if blacklisted_show_runtime(show, blacklist_settings.blacklisted_min_runtime): blacklisted = True - if trakt_blacklisted_show_id(show, blacklist_settings.blacklisted_tvdb_ids): + if blacklisted_show_id(show, blacklist_settings.blacklisted_tvdb_ids): blacklisted = True except Exception: log.exception("Exception determining if show was blacklisted %s: ", show) return blacklisted -############################################################ -# RADARR -############################################################ - -def radarr_movies_to_tmdb_dict(radarr_movies): - movies = {} - try: - for tmp in radarr_movies: - if 'tmdbId' not in tmp: - log.debug("Could not handle movie: %s", tmp['title']) - continue - movies[tmp['tmdbId']] = tmp - return movies - except Exception: - log.exception("Exception processing Radarr movies to TMDB dict: ") - return None - - -def radarr_remove_existing_movies(radarr_movies, trakt_movies): - new_movies_list = [] - - if not radarr_movies or not trakt_movies: - log.error("Inappropriate parameters were supplied") - return None - - try: - # turn radarr movies result into a dict with tmdb id as keys - processed_movies = radarr_movies_to_tmdb_dict(radarr_movies) - if not processed_movies: - return None - - # loop list adding to movies that do not already exist - for tmp in trakt_movies: - if 'movie' not in tmp or 'ids' not in tmp['movie'] or 'tmdb' not in tmp['movie']['ids']: - log.debug("Skipping movie because it did not have required fields: %s", tmp) - continue - # check if movie exists in processed_movies - if tmp['movie']['ids']['tmdb'] in processed_movies: - log.debug("Removing existing movie: %s", tmp['movie']['title']) - continue - - new_movies_list.append(tmp) - - log.debug("Filtered %d Trakt movies to %d movies that weren't already in Radarr", len(trakt_movies), - len(new_movies_list)) - return new_movies_list - except Exception: - log.exception("Exception removing existing movies from Trakt list: ") - return None - - -def trakt_blacklisted_movie_genre(movie, genres): +def blacklisted_movie_genre(movie, genres): blacklisted = False try: if not movie['movie']['genres']: @@ -278,7 +145,7 @@ def trakt_blacklisted_movie_genre(movie, genres): return blacklisted -def trakt_blacklisted_movie_year(movie, earliest_year, latest_year): +def blacklisted_movie_year(movie, earliest_year, latest_year): blacklisted = False try: year = movie['movie']['year'] @@ -294,7 +161,7 @@ def trakt_blacklisted_movie_year(movie, earliest_year, latest_year): return blacklisted -def trakt_blacklisted_movie_country(movie, allowed_countries): +def blacklisted_movie_country(movie, allowed_countries): blacklisted = False try: if not movie['movie']['country']: @@ -311,7 +178,7 @@ def trakt_blacklisted_movie_country(movie, allowed_countries): return blacklisted -def trakt_blacklisted_movie_title(movie, blacklisted_keywords): +def blacklisted_movie_title(movie, blacklisted_keywords): blacklisted = False try: if not movie['movie']['title']: @@ -329,7 +196,7 @@ def trakt_blacklisted_movie_title(movie, blacklisted_keywords): return blacklisted -def trakt_blacklisted_movie_runtime(movie, lowest_runtime): +def blacklisted_movie_runtime(movie, lowest_runtime): blacklisted = False try: if not movie['movie']['runtime'] or not isinstance(movie['movie']['runtime'], int): @@ -345,7 +212,7 @@ def trakt_blacklisted_movie_runtime(movie, lowest_runtime): return blacklisted -def trakt_blacklisted_movie_id(movie, blacklisted_ids): +def blacklisted_movie_id(movie, blacklisted_ids): blacklisted = False try: if not movie['movie']['ids']['tmdb'] or not isinstance(movie['movie']['ids']['tmdb'], int): @@ -361,52 +228,34 @@ def trakt_blacklisted_movie_id(movie, blacklisted_ids): return blacklisted -def trakt_is_movie_blacklisted(movie, blacklist_settings): +def is_movie_blacklisted(movie, blacklist_settings): blacklisted = False try: - if trakt_blacklisted_movie_title(movie, blacklist_settings.blacklist_title_keywords): + if blacklisted_movie_title(movie, blacklist_settings.blacklist_title_keywords): blacklisted = True - if trakt_blacklisted_movie_year(movie, blacklist_settings.blacklisted_min_year, - blacklist_settings.blacklisted_max_year): + if blacklisted_movie_year(movie, blacklist_settings.blacklisted_min_year, + blacklist_settings.blacklisted_max_year): blacklisted = True - if trakt_blacklisted_movie_country(movie, blacklist_settings.allowed_countries): + if blacklisted_movie_country(movie, blacklist_settings.allowed_countries): blacklisted = True - if trakt_blacklisted_movie_genre(movie, blacklist_settings.blacklisted_genres): + if blacklisted_movie_genre(movie, blacklist_settings.blacklisted_genres): blacklisted = True - if trakt_blacklisted_movie_runtime(movie, blacklist_settings.blacklisted_min_runtime): + if blacklisted_movie_runtime(movie, blacklist_settings.blacklisted_min_runtime): blacklisted = True - if trakt_blacklisted_movie_id(movie, blacklist_settings.blacklisted_tmdb_ids): + if blacklisted_movie_id(movie, blacklist_settings.blacklisted_tmdb_ids): blacklisted = True except Exception: log.exception("Exception determining if movie was blacklisted %s: ", movie) return blacklisted -############################################################ -# MISC -############################################################ - - -def get_response_dict(response, key_field=None, key_value=None): - found_response = None +def extract_list_user_and_key_from_url(list_url): try: - if isinstance(response, list): - if not key_field or not key_value: - found_response = response[0] - else: - for result in response: - if isinstance(result, dict) and key_field in result and result[key_field] == key_value: - found_response = result - break - - if not found_response: - log.error("Unable to find a result with key %s where the value is %s", key_field, key_value) - - elif isinstance(response, dict): - found_response = response - else: - log.error("Unexpected response instance type of %s for %s", type(response).__name__, response) - - except Exception: - log.exception("Exception determining response for %s: ", response) - return found_response + import re + list_user = re.search('\/users\/([^/]*)', list_url).group(1) + list_key = re.search('\/lists\/([^/]*)', list_url).group(1) + + return list_user, list_key + except: + log.error('The URL "%s" is not in the correct format', list_url) + exit() diff --git a/media/pvr.py b/media/pvr.py new file mode 100644 index 0000000..f51d827 --- /dev/null +++ b/media/pvr.py @@ -0,0 +1,141 @@ +import os.path +from abc import ABC, abstractmethod + +import backoff +import requests + +from helpers.misc import backoff_handler +from helpers import str as misc_str +from helpers import misc +from misc.log import logger + +log = logger.get_logger(__name__) + + +class PVR(ABC): + def __init__(self, server_url, api_key): + self.server_url = server_url + self.api_key = api_key + self.headers = { + 'Content-Type': 'application/json', + 'X-Api-Key': self.api_key, + } + + def validate_api_key(self): + try: + # request system status to validate api_key + req = requests.get( + os.path.join(misc_str.ensure_endswith(self.server_url, "/"), 'api/system/status'), + headers=self.headers, + timeout=60 + ) + log.debug("Request Response: %d", req.status_code) + + if req.status_code == 200 and 'version' in req.json(): + return True + return False + except Exception: + log.exception("Exception validating api_key: ") + return False + + @abstractmethod + def get_objects(self): + pass + + @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) + def _get_objects(self, endpoint): + try: + # make request + req = requests.get( + os.path.join(misc_str.ensure_endswith(self.server_url, "/"), endpoint), + headers=self.headers, + timeout=60 + ) + log.debug("Request URL: %s", req.url) + log.debug("Request Response: %d", req.status_code) + + if req.status_code == 200: + resp_json = req.json() + log.debug("Found %d objects", len(resp_json)) + return resp_json + else: + log.error("Failed to retrieve all objects, request response: %d", req.status_code) + except Exception: + log.exception("Exception retrieving objects: ") + return None + + @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) + def get_profile_id(self, profile_name): + try: + # make request + req = requests.get( + os.path.join(misc_str.ensure_endswith(self.server_url, "/"), 'api/profile'), + headers=self.headers, + timeout=60 + ) + log.debug("Request URL: %s", req.url) + log.debug("Request Response: %d", req.status_code) + + if req.status_code == 200: + resp_json = req.json() + for profile in resp_json: + if profile['name'].lower() == profile_name.lower(): + log.debug("Found id of %s profile: %d", profile_name, profile['id']) + return profile['id'] + log.debug("Profile %s with id %d did not match %s", profile['name'], profile['id'], profile_name) + else: + log.error("Failed to retrieve all quality profiles, request response: %d", req.status_code) + except Exception: + log.exception("Exception retrieving id of profile %s: ", profile_name) + return None + + def _prepare_add_object_payload(self, title, title_slug, profile_id, root_folder): + return { + 'title': title, + 'titleSlug': title_slug, + 'qualityProfileId': profile_id, + 'images': [], + 'monitored': True, + 'rootFolderPath': root_folder, + 'addOptions': { + 'ignoreEpisodesWithFiles': False, + 'ignoreEpisodesWithoutFiles': False, + } + } + + @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) + def _add_object(self, endpoint, payload, identifier_field, identifier): + try: + # make request + req = requests.post( + os.path.join(misc_str.ensure_endswith(self.server_url, "/"), endpoint), + headers=self.headers, + json=payload, + timeout=60 + ) + log.debug("Request URL: %s", req.url) + log.debug("Request Payload: %s", payload) + log.debug("Request Response Code: %d", req.status_code) + log.debug("Request Response Text:\n%s", req.text) + + response_json = None + if 'json' in req.headers['Content-Type'].lower(): + response_json = misc.get_response_dict(req.json(), identifier_field, identifier) + + if (req.status_code == 201 or req.status_code == 200) \ + and (response_json and identifier_field in response_json) \ + and response_json[identifier_field] == identifier: + log.debug("Successfully added %s (%d)", payload['title'], identifier) + return True + elif response_json and ('errorMessage' in response_json or 'message' in response_json): + message = response_json['errorMessage'] if 'errorMessage' in response_json else response_json['message'] + + log.error("Failed to add %s (%d) - status_code: %d, reason: %s", payload['title'], identifier, + req.status_code, message) + return False + else: + log.error("Failed to add %s (%d), unexpected response:\n%s", payload['title'], identifier, req.text) + return False + except Exception: + log.exception("Exception adding %s (%d): ", payload['title'], identifier) + return None diff --git a/media/radarr.py b/media/radarr.py index 981af7e..7d52f92 100644 --- a/media/radarr.py +++ b/media/radarr.py @@ -1,143 +1,28 @@ -import os.path - import backoff -import requests -from misc import helpers -from misc import str as misc_str +from helpers.misc import backoff_handler, dict_merge +from media.pvr import PVR from misc.log import logger log = logger.get_logger(__name__) -def backoff_handler(details): - log.warning("Backing off {wait:0.1f} seconds afters {tries} tries " - "calling function {target} with args {args} and kwargs " - "{kwargs}".format(**details)) - - -class Radarr: - def __init__(self, server_url, api_key): - self.server_url = server_url - self.api_key = api_key - self.headers = { - 'Content-Type': 'application/json', - 'X-Api-Key': self.api_key, - } - - def validate_api_key(self): - try: - # request system status to validate api_key - req = requests.get( - os.path.join(misc_str.ensure_endswith(self.server_url, "/"), 'api/system/status'), - headers=self.headers, - timeout=60 - ) - log.debug("Request Response: %d", req.status_code) - - if req.status_code == 200 and 'version' in req.json(): - return True - return False - except Exception: - log.exception("Exception validating api_key: ") - return False - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) - def get_movies(self): - try: - # make request - req = requests.get( - os.path.join(misc_str.ensure_endswith(self.server_url, "/"), 'api/movie'), - headers=self.headers, - timeout=60 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Response: %d", req.status_code) - - if req.status_code == 200: - resp_json = req.json() - log.debug("Found %d movies", len(resp_json)) - return resp_json - else: - log.error("Failed to retrieve all movies, request response: %d", req.status_code) - except Exception: - log.exception("Exception retrieving movies: ") - return None - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) - def get_profile_id(self, profile_name): - try: - # make request - req = requests.get( - os.path.join(misc_str.ensure_endswith(self.server_url, "/"), 'api/profile'), - headers=self.headers, - timeout=60 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Response: %d", req.status_code) - - if req.status_code == 200: - resp_json = req.json() - for profile in resp_json: - if profile['name'].lower() == profile_name.lower(): - log.debug("Found id of %s profile: %d", profile_name, profile['id']) - return profile['id'] - log.debug("Profile %s with id %d did not match %s", profile['name'], profile['id'], profile_name) - else: - log.error("Failed to retrieve all quality profiles, request response: %d", req.status_code) - except Exception: - log.exception("Exception retrieving id of profile %s: ", profile_name) - return None +class Radarr(PVR): + def get_objects(self): + return self._get_objects('api/movie') @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) def add_movie(self, movie_tmdbid, movie_title, movie_year, movie_title_slug, profile_id, root_folder, search_missing=False): - try: - # generate payload - payload = { - 'tmdbId': movie_tmdbid, - 'title': movie_title, - 'year': movie_year, - 'qualityProfileId': profile_id, - 'images': [], - 'monitored': True, - 'rootFolderPath': root_folder, - 'minimumAvailability': 'released', - 'titleSlug': movie_title_slug, - 'addOptions': { - 'ignoreEpisodesWithFiles': False, - 'ignoreEpisodesWithoutFiles': False, - 'searchForMovie': search_missing - } + payload = self._prepare_add_object_payload(movie_title, movie_title_slug, profile_id, root_folder) + + payload = dict_merge(payload, { + 'tmdbId': movie_tmdbid, + 'year': movie_year, + 'minimumAvailability': 'released', + 'addOptions': { + 'searchForMovie': search_missing } + }) - # make request - req = requests.post( - os.path.join(misc_str.ensure_endswith(self.server_url, "/"), 'api/movie'), - headers=self.headers, - json=payload, - timeout=60 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Request Response Code: %d", req.status_code) - log.debug("Request Response Text:\n%s", req.text) - - response_json = None - if 'json' in req.headers['Content-Type'].lower(): - response_json = helpers.get_response_dict(req.json(), 'tmdbId', movie_tmdbid) - - if (req.status_code == 201 or req.status_code == 200) and (response_json and 'tmdbId' in response_json) \ - and response_json['tmdbId'] == movie_tmdbid: - log.debug("Successfully added %s (%d)", movie_title, movie_tmdbid) - return True - elif response_json and 'message' in response_json: - log.error("Failed to add %s (%d) - status_code: %d, reason: %s", movie_title, movie_tmdbid, - req.status_code, response_json['message']) - return False - else: - log.error("Failed to add %s (%d), unexpected response:\n%s", movie_title, movie_tmdbid, req.text) - return False - except Exception: - log.exception("Exception adding movie %s (%d): ", movie_title, movie_tmdbid) - return None + return self._add_object('api/movie', payload, identifier_field='tmdbId', identifier=movie_tmdbid) diff --git a/media/sonarr.py b/media/sonarr.py index 6caa082..637e5ad 100644 --- a/media/sonarr.py +++ b/media/sonarr.py @@ -2,115 +2,18 @@ import os.path import backoff import requests +from helpers.misc import backoff_handler, dict_merge -from misc import helpers -from misc import str as misc_str +from helpers import str as misc_str +from media.pvr import PVR from misc.log import logger log = logger.get_logger(__name__) -def backoff_handler(details): - log.warning("Backing off {wait:0.1f} seconds afters {tries} tries " - "calling function {target} with args {args} and kwargs " - "{kwargs}".format(**details)) - - -class Sonarr: - def __init__(self, server_url, api_key): - self.server_url = server_url - self.api_key = api_key - self.headers = { - 'X-Api-Key': self.api_key, - } - - def validate_api_key(self): - try: - # request system status to validate api_key - req = requests.get(os.path.join(misc_str.ensure_endswith(self.server_url, "/"), 'api/system/status'), - headers=self.headers, timeout=60) - log.debug("Request Response: %d", req.status_code) - - if req.status_code == 200 and 'version' in req.json(): - return True - return False - except Exception: - log.exception("Exception validating api_key: ") - return False - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) - def get_series(self): - try: - # make request - req = requests.get( - os.path.join(misc_str.ensure_endswith(self.server_url, "/"), 'api/series'), - headers=self.headers, - timeout=60 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Response: %d", req.status_code) - - if req.status_code == 200: - resp_json = req.json() - log.debug("Found %d shows", len(resp_json)) - return resp_json - else: - log.error("Failed to retrieve all shows, request response: %d", req.status_code) - except Exception: - log.exception("Exception retrieving show: ") - return None - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) - def get_profile_id(self, profile_name): - try: - # make request - req = requests.get( - os.path.join(misc_str.ensure_endswith(self.server_url, "/"), 'api/profile'), - headers=self.headers, - timeout=60 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Response: %d", req.status_code) - - if req.status_code == 200: - resp_json = req.json() - log.debug("Found %d quality profiles", len(resp_json)) - for profile in resp_json: - if profile['name'].lower() == profile_name.lower(): - log.debug("Found id of %s profile: %d", profile_name, profile['id']) - return profile['id'] - log.debug("Profile %s with id %d did not match %s", profile['name'], profile['id'], profile_name) - else: - log.error("Failed to retrieve all quality profiles, request response: %d", req.status_code) - except Exception: - log.exception("Exception retrieving id of profile %s: ", profile_name) - return None - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) - def get_tag_id(self, tag_name): - try: - # make request - req = requests.get( - os.path.join(misc_str.ensure_endswith(self.server_url, "/"), 'api/tag'), - headers=self.headers, - timeout=60 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Response: %d", req.status_code) - - if req.status_code == 200: - resp_json = req.json() - log.debug("Found %d tags", len(resp_json)) - for tag in resp_json: - if tag['label'].lower() == tag_name.lower(): - log.debug("Found id of %s tag: %d", tag_name, tag['id']) - return tag['id'] - log.debug("Tag %s with id %d did not match %s", tag['label'], tag['id'], tag_name) - else: - log.error("Failed to retrieve all tags, request response: %d", req.status_code) - except Exception: - log.exception("Exception retrieving id of tag %s: ", tag_name) - return None +class Sonarr(PVR): + def get_objects(self): + return self._get_objects('api/series') @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) def get_tags(self): @@ -140,53 +43,16 @@ class Sonarr: @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) def add_series(self, series_tvdbid, series_title, series_title_slug, profile_id, root_folder, tag_ids=None, search_missing=False): - try: - # generate payload - payload = { - 'tvdbId': series_tvdbid, - 'title': series_title, - 'titleSlug': series_title_slug, - 'qualityProfileId': profile_id, - 'tags': [] if not tag_ids or not isinstance(tag_ids, list) else tag_ids, - 'images': [], - 'seasons': [], - 'seasonFolder': True, - 'monitored': True, - 'rootFolderPath': root_folder, - 'addOptions': { - 'ignoreEpisodesWithFiles': False, - 'ignoreEpisodesWithoutFiles': False, - 'searchForMissingEpisodes': search_missing - } + payload = self._prepare_add_object_payload(series_title, series_title_slug, profile_id, root_folder) + + payload = dict_merge(payload, { + 'tvdbId': series_tvdbid, + 'tags': [] if not tag_ids or not isinstance(tag_ids, list) else tag_ids, + 'seasons': [], + 'seasonFolder': True, + 'addOptions': { + 'searchForMissingEpisodes': search_missing } + }) - # make request - req = requests.post( - os.path.join(misc_str.ensure_endswith(self.server_url, "/"), 'api/series'), - headers=self.headers, - json=payload, - timeout=60 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Request Response Code: %d", req.status_code) - log.debug("Request Response Text:\n%s", req.text) - - response_json = None - if 'json' in req.headers['Content-Type'].lower(): - response_json = helpers.get_response_dict(req.json(), 'tvdbId', series_tvdbid) - - if (req.status_code == 201 or req.status_code == 200) and (response_json and 'tvdbId' in response_json) \ - and response_json['tvdbId'] == series_tvdbid: - log.debug("Successfully added %s (%d)", series_title, series_tvdbid) - return True - elif response_json and 'errorMessage' in response_json: - log.error("Failed to add %s (%d) - status_code: %d, reason: %s", series_title, series_tvdbid, - req.status_code, response_json['errorMessage']) - return False - else: - log.error("Failed to add %s (%d), unexpected response:\n%s", series_title, series_tvdbid, req.text) - return False - except Exception: - log.exception("Exception adding show %s (%d): ", series_title, series_tvdbid) - return None + return self._add_object('api/series', payload, identifier_field='tvdbId', identifier=series_tvdbid) diff --git a/media/trakt.py b/media/trakt.py index cb46a0e..ddc2d7a 100644 --- a/media/trakt.py +++ b/media/trakt.py @@ -3,43 +3,124 @@ import time import backoff import requests +from helpers.misc import backoff_handler, dict_merge +from helpers.trakt import extract_list_user_and_key_from_url from misc.log import logger log = logger.get_logger(__name__) -def backoff_handler(details): - log.warning("Backing off {wait:0.1f} seconds afters {tries} tries " - "calling function {target} with args {args} and kwargs " - "{kwargs}".format(**details)) - - class Trakt: non_user_lists = ['anticipated', 'trending', 'popular', 'boxoffice'] def __init__(self, cfg): self.cfg = cfg - self.client_id = cfg.trakt.client_id - self.client_secret = cfg.trakt.client_secret - self.headers = { - 'Content-Type': 'application/json', - 'trakt-api-version': '2', - 'trakt-api-key': self.client_id - } + + ############################################################ + # Requests + ############################################################ + + def _make_request(self, url, payload={}, authenticate_user=None): + headers, authenticate_user = self._headers(authenticate_user) + + if authenticate_user: + url = url.replace('{authenticate_user}', authenticate_user) + + # make request + req = requests.get(url, headers=headers, params=payload, timeout=30) + log.debug("Request URL: %s", req.url) + log.debug("Request Payload: %s", payload) + log.debug("Request User: %s", authenticate_user) + log.debug("Response Code: %d", req.status_code) + + return req + + @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) + def _make_item_request(self, url, object_name, payload={}): + payload = dict_merge(payload, {'extended': 'full'}) + + try: + req = self._make_request(url, payload) + + if req.status_code == 200: + resp_json = req.json() + return resp_json + elif req.status_code == 401: + log.error("The authentication to Trakt is revoked. Please re-authenticate.") + exit() + else: + log.error("Failed to retrieve %s, request response: %d", object_name, req.status_code) + return None + except Exception: + log.exception("Exception retrieving %s: ", object_name) + return None + + @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) + def _make_items_request(self, url, limit, languages, type_name, object_name, authenticate_user=None, payload={}, + sleep_between=5, genres=None): + if not languages: + languages = ['en'] + + payload = dict_merge(payload, {'extended': 'full', 'limit': limit, 'page': 1, 'languages': ','.join(languages)}) + if genres: + payload['genres'] = genres + + processed = [] + + type_name = type_name.replace('{authenticate_user}', self._user_used_for_authentication(authenticate_user)) + + try: + while True: + req = self._make_request(url, payload, authenticate_user) + + current_page = payload['page'] + total_pages = 0 if 'X-Pagination-Page-Count' not in req.headers else int( + req.headers['X-Pagination-Page-Count']) + + log.debug("Response Page: %d of %d", current_page, total_pages) + + if req.status_code == 200: + resp_json = req.json() + + for item in resp_json: + if item not in processed: + if object_name.rstrip('s') not in item and 'title' in item: + processed.append({object_name.rstrip('s'): item}) + else: + processed.append(item) + + # check if we have fetched the last page, break if so + if total_pages == 0: + log.debug("There were no more pages to retrieve") + break + elif current_page >= total_pages: + log.debug("There are no more pages to retrieve results from") + break + else: + log.info("There are %d pages left to retrieve results from", total_pages - current_page) + payload['page'] += 1 + time.sleep(sleep_between) + elif req.status_code == 401: + log.error("The authentication to Trakt is revoked. Please re-authenticate.") + exit() + else: + log.error("Failed to retrieve %s %s, request response: %d", type_name, object_name, req.status_code) + break + + if len(processed): + log.debug("Found %d %s %s", len(processed), type_name, object_name) + return processed + return None + except Exception: + log.exception("Exception retrieving %s %s: ", type_name, object_name) + return None def validate_client_id(self): try: - # request trending shows to determine if client_id is valid - payload = {'extended': 'full', 'limit': 1000} - - # make request - req = requests.get( - 'https://api.trakt.tv/shows/anticipated', - headers=self.headers, - params=payload, - timeout=30 + # request anticipated shows to validate client_id + req = self._make_request( + url='https://api.trakt.tv/shows/anticipated', ) - log.debug("Request Response: %d", req.status_code) if req.status_code == 200: return True @@ -49,16 +130,19 @@ class Trakt: return False ############################################################ - # OAuth Authentication Initialisation + # OAuth Authentication ############################################################ def __oauth_request_device_code(self): log.info("We're talking to Trakt to get your verification code. Please wait a moment...") - payload = {'client_id': self.client_id} + payload = {'client_id': self.cfg.trakt.client_id} + + print(self._headers_without_authentication()) # Request device code - req = requests.post('https://api.trakt.tv/oauth/device/code', params=payload, headers=self.headers) + req = requests.post('https://api.trakt.tv/oauth/device/code', params=payload, + headers=self._headers_without_authentication()) device_code_response = req.json() # Display needed information to the user @@ -77,7 +161,7 @@ class Trakt: access_token = access_token_response['access_token'] # But first we need to find out what user this token belongs to - temp_headers = self.headers + temp_headers = self._headers_without_authentication() temp_headers['Authorization'] = 'Bearer ' + access_token req = requests.get('https://api.trakt.tv/users/me', headers=temp_headers) @@ -117,11 +201,12 @@ class Trakt: log.debug('Polling Trakt for the %sth time; %s seconds left', tries, polling_expire - round(time.time() - polling_start)) - payload = {'code': device_code, 'client_id': self.client_id, 'client_secret': self.client_secret, - 'grant_type': 'authorization_code'} + payload = {'code': device_code, 'client_id': self.cfg.trakt.client_id, + 'client_secret': self.cfg.trakt.client_secret, 'grant_type': 'authorization_code'} # Poll Trakt for access token - req = requests.post('https://api.trakt.tv/oauth/device/token', params=payload, headers=self.headers) + req = requests.post('https://api.trakt.tv/oauth/device/token', params=payload, + headers=self._headers_without_authentication()) success, status_code = self.__oauth_process_token_request(req) @@ -135,10 +220,11 @@ class Trakt: return False def __oauth_refresh_access_token(self, refresh_token): - payload = {'refresh_token': refresh_token, 'client_id': self.client_id, 'client_secret': self.client_secret, - 'grant_type': 'refresh_token'} + payload = {'refresh_token': refresh_token, 'client_id': self.cfg.trakt.client_id, + 'client_secret': self.cfg.trakt.client_secret, 'grant_type': 'refresh_token'} - req = requests.post('https://api.trakt.tv/oauth/token', params=payload, headers=self.headers) + req = requests.post('https://api.trakt.tv/oauth/token', params=payload, + headers=self._headers_without_authentication()) success, status_code = self.__oauth_process_token_request(req) @@ -156,819 +242,199 @@ class Trakt: log.exception("Exception occurred when authenticating user") return False - def oauth_headers(self, user): - headers = self.headers - - if user is None: - users = self.cfg['trakt'] - - if 'client_id' in users.keys(): - users.pop('client_id') - - if 'client_secret' in users.keys(): - users.pop('client_secret') + def _get_first_authenticated_user(self): + import copy - if len(users) > 0: - user = list(users.keys())[0] + users = copy.copy(self.cfg.trakt) - log.debug('No user provided, so default to the first user in the config (%s)', user) - elif user not in self.cfg['trakt'].keys(): - log.error( - 'The user %s you specified to use for authentication is not authenticated yet. ' - 'Authenticate the user first, before you use it to retrieve lists.', - user) + if 'client_id' in users.keys(): + users.pop('client_id') - exit() + if 'client_secret' in users.keys(): + users.pop('client_secret') - # If there is no default user, try without authentication - if user is None: - log.info('Using no authentication') + if len(users) > 0: + return list(users.keys())[0] - return headers, user + def _user_is_authenticated(self, user): + return user in self.cfg['trakt'].keys() + def _renew_oauth_token_if_expired(self, user): token_information = self.cfg['trakt'][user] + # Check if the acces_token for the user is expired expires_at = token_information['created_at'] + token_information['expires_in'] - if expires_at < round(time.time()): log.info("The access token for the user %s has expired. We're requesting a new one; please wait a moment.", user) if self.__oauth_refresh_access_token(token_information["refresh_token"]): - log.info("The access token for the user %s has been refreshed. Please restart the application.", - user) - - headers['Authorization'] = 'Bearer ' + token_information['access_token'] - - return headers, user - - ############################################################ - # Shows - ############################################################ - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) - def get_show(self, show_id): - try: - # generate payload - payload = {'extended': 'full'} - - # make request - req = requests.get( - 'https://api.trakt.tv/shows/%s' % str(show_id), - headers=self.headers, - params=payload, - timeout=30 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Response Code: %d", req.status_code) - - if req.status_code == 200: - resp_json = req.json() - return resp_json - else: - log.error("Failed to retrieve show, request response: %d", req.status_code) - return None + log.info("The access token for the user %s has been refreshed. Please restart the application.", user) - except Exception: - log.exception("Exception retrieving show: ") - return None + def _user_used_for_authentication(self, user=None): + if user is None: + user = self._get_first_authenticated_user() + elif not self._user_is_authenticated(user): + log.error('The user %s you specified to use for authentication is not authenticated yet. ' + + 'Authenticate the user first, before you use it to retrieve lists.', user) - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) - def get_anticipated_shows(self, limit=1000, languages=None): - try: - processed_shows = [] + exit() - if languages is None: - languages = ['en'] + return user - # generate payload - payload = {'extended': 'full', 'limit': limit, 'page': 1} - if languages: - payload['languages'] = ','.join(languages) + def _headers_without_authentication(self): + return { + 'Content-Type': 'application/json', + 'trakt-api-version': '2', + 'trakt-api-key': self.cfg.trakt.client_id + } - # make request - while True: - req = requests.get( - 'https://api.trakt.tv/shows/anticipated', - headers=self.headers, - params=payload, - timeout=30 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Response Code: %d", req.status_code) - log.debug("Response Page: %d of %d", payload['page'], - 0 if 'X-Pagination-Page-Count' not in req.headers else int( - req.headers['X-Pagination-Page-Count'])) + def _headers(self, user=None): + headers = self._headers_without_authentication() - if req.status_code == 200: - resp_json = req.json() + user = self._user_used_for_authentication(user) - for show in resp_json: - if show not in processed_shows: - processed_shows.append(show) + if user is not None: + self._renew_oauth_token_if_expired(user) + headers['Authorization'] = 'Bearer ' + self.cfg['trakt'][user]['access_token'] + else: + log.info('No user') - # check if we have fetched the last page, break if so - if 'X-Pagination-Page-Count' not in req.headers or not int(req.headers['X-Pagination-Page-Count']): - log.debug("There was no more pages to retrieve") - break - elif payload['page'] >= int(req.headers['X-Pagination-Page-Count']): - log.debug("There are no more pages to retrieve results from") - break - else: - log.info("There are %d pages left to retrieve results from", - int(req.headers['X-Pagination-Page-Count']) - payload['page']) - payload['page'] += 1 - time.sleep(5) + return headers, user - else: - log.error("Failed to retrieve anticipated shows, request response: %d", req.status_code) - break + ############################################################ + # Shows + ############################################################ - if len(processed_shows): - log.debug("Found %d anticipated shows", len(processed_shows)) - return processed_shows - return None - except Exception: - log.exception("Exception retrieving anticipated shows: ") - return None + def get_show(self, show_id): + return self._make_item_request( + url='https://api.trakt.tv/shows/%s' % str(show_id), + object_name='show', + ) + + def get_trending_shows(self, limit=1000, languages=None, genres=None): + return self._make_items_request( + url='https://api.trakt.tv/shows/trending', + limit=limit, + languages=languages, + object_name='shows', + type_name='trending', + genres=genres + ) + + def get_popular_shows(self, limit=1000, languages=None, genres=None): + return self._make_items_request( + url='https://api.trakt.tv/shows/popular', + limit=limit, + languages=languages, + object_name='shows', + type_name='popular', + genres=genres + ) + + def get_anticipated_shows(self, limit=1000, languages=None, genres=None): + return self._make_items_request( + url='https://api.trakt.tv/shows/anticipated', + limit=limit, + languages=languages, + object_name='shows', + type_name='anticipated', + genres=genres + ) - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) def get_watchlist_shows(self, authenticate_user=None, limit=1000, languages=None): - try: - processed_shows = [] - - if languages is None: - languages = ['en'] - - # generate payload - payload = {'extended': 'full', 'limit': limit, 'page': 1} - if languages: - payload['languages'] = ','.join(languages) - - # make request - while True: - headers, authenticate_user = self.oauth_headers(authenticate_user) - - req = requests.get('https://api.trakt.tv/users/' + authenticate_user + '/watchlist/movies', - params=payload, - headers=headers, - timeout=30) - log.debug("Request User: %s", authenticate_user) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Response Code: %d", req.status_code) - log.debug("Response Page: %d of %d", payload['page'], - 0 if 'X-Pagination-Page-Count' not in req.headers else int( - req.headers['X-Pagination-Page-Count'])) + return self._make_items_request( + url='https://api.trakt.tv/users/{authenticate_user}/watchlist/shows', + authenticate_user=authenticate_user, + limit=limit, + languages=languages, + object_name='shows', + type_name='watchlist from {authenticate_user}', + ) - if req.status_code == 200: - resp_json = req.json() - - for show in resp_json: - if show not in processed_shows: - processed_shows.append(show) - - # check if we have fetched the last page, break if so - if 'X-Pagination-Page-Count' not in req.headers or not int(req.headers['X-Pagination-Page-Count']): - log.debug("There was no more pages to retrieve") - break - elif payload['page'] >= int(req.headers['X-Pagination-Page-Count']): - log.debug("There are no more pages to retrieve results from") - break - else: - log.info("There are %d pages left to retrieve results from", - int(req.headers['X-Pagination-Page-Count']) - payload['page']) - payload['page'] += 1 - time.sleep(5) - elif req.status_code == 401: - log.error("The authentication to Trakt is revoked. Please re-authenticate.") - - exit() - else: - log.error("Failed to retrieve shows on watchlist from %s, request response: %d", authenticate_user, - req.status_code) - break - - if len(processed_shows): - log.debug("Found %d shows on watchlist from %s", len(processed_shows), authenticate_user) - - return processed_shows - return None - except Exception: - log.exception("Exception retrieving shows on watchlist") - return None - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) def get_user_list_shows(self, list_url, authenticate_user=None, limit=1000, languages=None): - try: - processed_shows = [] + list_user, list_key = extract_list_user_and_key_from_url(list_url) - if languages is None: - languages = ['en'] + log.debug('Fetching %s from %s', list_key, list_user) - # generate payload - payload = {'extended': 'full', 'limit': limit, 'page': 1} - if languages: - payload['languages'] = ','.join(languages) - - try: - import re - list_user = re.search('\/users\/([^/]*)', list_url).group(1) - list_key = re.search('\/lists\/([^/]*)', list_url).group(1) - except: - log.error('The URL "%s" is not in the correct format', list_url) - - exit() - - log.debug('Fetching %s from %s', list_key, list_user) - - # make request - while True: - headers, authenticate_user = self.oauth_headers(authenticate_user) - - req = requests.get('https://api.trakt.tv/users/' + list_user + '/lists/' + list_key + '/items/shows', - params=payload, - headers=headers, - timeout=30) - log.debug("Request User: %s", authenticate_user) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Response Code: %d", req.status_code) - log.debug("Response Page: %d of %d", payload['page'], - 0 if 'X-Pagination-Page-Count' not in req.headers else int( - req.headers['X-Pagination-Page-Count'])) - - if req.status_code == 200: - resp_json = req.json() - - for show in resp_json: - if show not in processed_shows: - processed_shows.append(show) - - # check if we have fetched the last page, break if so - if 'X-Pagination-Page-Count' not in req.headers or not int(req.headers['X-Pagination-Page-Count']): - log.debug("There was no more pages to retrieve") - break - elif payload['page'] >= int(req.headers['X-Pagination-Page-Count']): - log.debug("There are no more pages to retrieve results from") - break - else: - log.info("There are %d pages left to retrieve results from", - int(req.headers['X-Pagination-Page-Count']) - payload['page']) - payload['page'] += 1 - time.sleep(5) - elif req.status_code == 401: - log.error("The authentication to Trakt is revoked. Please re-authenticate.") - - exit() - else: - log.error("Failed to retrieve shows on %s from %s, request response: %d", list_key, list_user, - req.status_code) - break - - if len(processed_shows): - log.debug("Found %d shows on %s from %s", len(processed_shows), list_key, list_user) - - return processed_shows - return None - except Exception: - log.exception("Exception retrieving shows on user list") - return None - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) - def get_trending_shows(self, limit=1000, languages=None): - try: - processed_shows = [] - - if languages is None: - languages = ['en'] - - # generate payload - payload = {'extended': 'full', 'limit': limit, 'page': 1} - if languages: - payload['languages'] = ','.join(languages) - - # make request - while True: - req = requests.get( - 'https://api.trakt.tv/shows/trending', - headers=self.headers, - params=payload, - timeout=30 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Response Code: %d", req.status_code) - log.debug("Response Page: %d of %d", payload['page'], - 0 if 'X-Pagination-Page-Count' not in req.headers else int( - req.headers['X-Pagination-Page-Count'])) - - if req.status_code == 200: - resp_json = req.json() - - for show in resp_json: - if show not in processed_shows: - processed_shows.append(show) - - # check if we have fetched the last page, break if so - if 'X-Pagination-Page-Count' not in req.headers or not int(req.headers['X-Pagination-Page-Count']): - log.debug("There was no more pages to retrieve") - break - elif payload['page'] >= int(req.headers['X-Pagination-Page-Count']): - log.debug("There are no more pages to retrieve results from") - break - else: - log.info("There are %d pages left to retrieve results from", - int(req.headers['X-Pagination-Page-Count']) - payload['page']) - payload['page'] += 1 - time.sleep(5) - - else: - log.error("Failed to retrieve trending shows, request response: %d", req.status_code) - break - - if len(processed_shows): - log.debug("Found %d trending shows", len(processed_shows)) - return processed_shows - return None - except Exception: - log.exception("Exception retrieving trending shows: ") - return None - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) - def get_popular_shows(self, limit=1000, languages=None): - try: - processed_shows = [] - - if languages is None: - languages = ['en'] - - # generate payload - payload = {'extended': 'full', 'limit': limit, 'page': 1} - if languages: - payload['languages'] = ','.join(languages) - - # make request - while True: - req = requests.get( - 'https://api.trakt.tv/shows/popular', - headers=self.headers, - params=payload, - timeout=30 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Response Code: %d", req.status_code) - log.debug("Response Page: %d of %d", payload['page'], - 0 if 'X-Pagination-Page-Count' not in req.headers else int( - req.headers['X-Pagination-Page-Count'])) - - if req.status_code == 200: - resp_json = req.json() - - # process list so it conforms to standard we expect ( e.g. {"show": {.....}} ) - for show in resp_json: - if show not in processed_shows: - processed_shows.append({'show': show}) - - # check if we have fetched the last page, break if so - if 'X-Pagination-Page-Count' not in req.headers or not int(req.headers['X-Pagination-Page-Count']): - log.debug("There was no more pages to retrieve") - break - elif payload['page'] >= int(req.headers['X-Pagination-Page-Count']): - log.debug("There are no more pages to retrieve results from") - break - else: - log.info("There are %d pages left to retrieve results from", - int(req.headers['X-Pagination-Page-Count']) - payload['page']) - payload['page'] += 1 - time.sleep(5) - - else: - log.error("Failed to retrieve popular shows, request response: %d", req.status_code) - break - - if len(processed_shows): - log.debug("Found %d popular shows", len(processed_shows)) - return processed_shows - return None - except Exception: - log.exception("Exception retrieving popular shows: ") - return None + return self._make_items_request( + url='https://api.trakt.tv/users/' + list_user + '/lists/' + list_key + '/items/shows', + authenticate_user=authenticate_user, + limit=limit, + languages=languages, + object_name='shows', + type_name=(list_key + ' from ' + list_user), + ) ############################################################ # Movies ############################################################ - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) def get_movie(self, movie_id): - try: - # generate payload - payload = {'extended': 'full'} - - # make request - req = requests.get( - 'https://api.trakt.tv/movies/%s' % str(movie_id), - headers=self.headers, - params=payload, - timeout=30 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Response Code: %d", req.status_code) - - if req.status_code == 200: - resp_json = req.json() - return resp_json - else: - log.error("Failed to retrieve movie, request response: %d", req.status_code) - return None - - except Exception: - log.exception("Exception retrieving movie: ") - return None - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) - def get_anticipated_movies(self, limit=1000, languages=None): - try: - processed_movies = [] - - if languages is None: - languages = ['en'] - - # generate payload - payload = {'extended': 'full', 'limit': limit, 'page': 1} - if languages: - payload['languages'] = ','.join(languages) - - # make request - while True: - req = requests.get( - 'https://api.trakt.tv/movies/anticipated', - headers=self.headers, - params=payload, - timeout=30 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Response Code: %d", req.status_code) - log.debug("Response Page: %d of %d", payload['page'], - 0 if 'X-Pagination-Page-Count' not in req.headers else int( - req.headers['X-Pagination-Page-Count'])) - - if req.status_code == 200: - resp_json = req.json() - - for movie in resp_json: - if movie not in processed_movies: - processed_movies.append(movie) - - # check if we have fetched the last page, break if so - if 'X-Pagination-Page-Count' not in req.headers or not int(req.headers['X-Pagination-Page-Count']): - log.debug("There was no more pages to retrieve") - break - elif payload['page'] >= int(req.headers['X-Pagination-Page-Count']): - log.debug("There are no more pages to retrieve results from") - break - else: - log.info("There are %d pages left to retrieve results from", - int(req.headers['X-Pagination-Page-Count']) - payload['page']) - payload['page'] += 1 - time.sleep(5) - - else: - log.error("Failed to retrieve anticipated movies, request response: %d", req.status_code) - break - - if len(processed_movies): - log.debug("Found %d anticipated movies", len(processed_movies)) - return processed_movies - return None - except Exception: - log.exception("Exception retrieving anticipated movies: ") - return None - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) - def get_trending_movies(self, limit=1000, languages=None): - try: - processed_movies = [] - - if languages is None: - languages = ['en'] - - # generate payload - payload = {'extended': 'full', 'limit': limit, 'page': 1} - if languages: - payload['languages'] = ','.join(languages) - - # make request - while True: - req = requests.get( - 'https://api.trakt.tv/movies/trending', - headers=self.headers, - params=payload, - timeout=30 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Response Code: %d", req.status_code) - log.debug("Response Page: %d of %d", payload['page'], - 0 if 'X-Pagination-Page-Count' not in req.headers else int( - req.headers['X-Pagination-Page-Count'])) - - if req.status_code == 200: - resp_json = req.json() - - for movie in resp_json: - if movie not in processed_movies: - processed_movies.append(movie) - - # check if we have fetched the last page, break if so - if 'X-Pagination-Page-Count' not in req.headers or not int(req.headers['X-Pagination-Page-Count']): - log.debug("There was no more pages to retrieve") - break - elif payload['page'] >= int(req.headers['X-Pagination-Page-Count']): - log.debug("There are no more pages to retrieve results from") - break - else: - log.info("There are %d pages left to retrieve results from", - int(req.headers['X-Pagination-Page-Count']) - payload['page']) - payload['page'] += 1 - time.sleep(5) - - else: - log.error("Failed to retrieve trending movies, request response: %d", req.status_code) - break - - if len(processed_movies): - log.debug("Found %d trending movies", len(processed_movies)) - return processed_movies - return None - except Exception: - log.exception("Exception retrieving trending movies: ") - return None - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) - def get_popular_movies(self, limit=1000, languages=None): - try: - processed_movies = [] - - if languages is None: - languages = ['en'] - - # generate payload - payload = {'extended': 'full', 'limit': limit, 'page': 1} - if languages: - payload['languages'] = ','.join(languages) - - # make request - while True: - req = requests.get( - 'https://api.trakt.tv/movies/popular', - headers=self.headers, - params=payload, - timeout=30 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Response Code: %d", req.status_code) - log.debug("Response Page: %d of %d", payload['page'], - 0 if 'X-Pagination-Page-Count' not in req.headers else int( - req.headers['X-Pagination-Page-Count'])) - - if req.status_code == 200: - resp_json = req.json() - - # process list so it conforms to standard we expect ( e.g. {"movie": {.....}} ) - for movie in resp_json: - if movie not in processed_movies: - processed_movies.append({'movie': movie}) + return self._make_item_request( + url='https://api.trakt.tv/movies/%s' % str(movie_id), + object_name='movie', + ) + + def get_trending_movies(self, limit=1000, languages=None, genres=None): + return self._make_items_request( + url='https://api.trakt.tv/movies/trending', + limit=limit, + languages=languages, + object_name='movies', + type_name='trending', + genres=genres + ) + + def get_popular_movies(self, limit=1000, languages=None, genres=None): + return self._make_items_request( + url='https://api.trakt.tv/movies/popular', + limit=limit, + languages=languages, + object_name='movies', + type_name='popular', + genres=genres + ) + + def get_anticipated_movies(self, limit=1000, languages=None, genres=None): + return self._make_items_request( + url='https://api.trakt.tv/movies/anticipated', + limit=limit, + languages=languages, + object_name='movies', + type_name='anticipated', + genres=genres + ) - # check if we have fetched the last page, break if so - if 'X-Pagination-Page-Count' not in req.headers or not int(req.headers['X-Pagination-Page-Count']): - log.debug("There was no more pages to retrieve") - break - elif payload['page'] >= int(req.headers['X-Pagination-Page-Count']): - log.debug("There are no more pages to retrieve results from") - break - else: - log.info("There are %d pages left to retrieve results from", - int(req.headers['X-Pagination-Page-Count']) - payload['page']) - payload['page'] += 1 - time.sleep(5) - - else: - log.error("Failed to retrieve popular movies, request response: %d", req.status_code) - break - - if len(processed_movies): - log.debug("Found %d popular movies", len(processed_movies)) - return processed_movies - return None - except Exception: - log.exception("Exception retrieving popular movies: ") - return None - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) def get_boxoffice_movies(self, limit=1000, languages=None): - try: - processed_movies = [] - - if languages is None: - languages = ['en'] - - # generate payload - payload = {'extended': 'full', 'limit': limit, 'page': 1} - if languages: - payload['languages'] = ','.join(languages) - - # make request - while True: - req = requests.get( - 'https://api.trakt.tv/movies/boxoffice', - headers=self.headers, - params=payload, - timeout=30 - ) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Response Code: %d", req.status_code) - log.debug("Response Page: %d of %d", payload['page'], - 0 if 'X-Pagination-Page-Count' not in req.headers else int( - req.headers['X-Pagination-Page-Count'])) - - if req.status_code == 200: - resp_json = req.json() - - for movie in resp_json: - if movie not in processed_movies: - processed_movies.append(movie) + return self._make_items_request( + url='https://api.trakt.tv/movies/boxoffice', + limit=limit, + languages=languages, + object_name='movies', + type_name='anticipated', + ) - # check if we have fetched the last page, break if so - if 'X-Pagination-Page-Count' not in req.headers or not int(req.headers['X-Pagination-Page-Count']): - log.debug("There was no more pages to retrieve") - break - elif payload['page'] >= int(req.headers['X-Pagination-Page-Count']): - log.debug("There are no more pages to retrieve results from") - break - else: - log.info("There are %d pages left to retrieve results from", - int(req.headers['X-Pagination-Page-Count']) - payload['page']) - payload['page'] += 1 - time.sleep(5) - - else: - log.error("Failed to retrieve boxoffice movies, request response: %d", req.status_code) - break - - if len(processed_movies): - log.debug("Found %d boxoffice movies", len(processed_movies)) - return processed_movies - return None - except Exception: - log.exception("Exception retrieving boxoffice movies: ") - return None - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) def get_watchlist_movies(self, authenticate_user=None, limit=1000, languages=None): - try: - processed_movies = [] - - if languages is None: - languages = ['en'] - - # generate payload - payload = {'extended': 'full', 'limit': limit, 'page': 1} - if languages: - payload['languages'] = ','.join(languages) - - # make request - while True: - headers, authenticate_user = self.oauth_headers(authenticate_user) - - req = requests.get('https://api.trakt.tv/users/' + authenticate_user + '/watchlist/movies', - params=payload, - headers=headers, - timeout=30) - log.debug("Request User: %s", authenticate_user) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Response Code: %d", req.status_code) - log.debug("Response Page: %d of %d", payload['page'], - 0 if 'X-Pagination-Page-Count' not in req.headers else int( - req.headers['X-Pagination-Page-Count'])) - - if req.status_code == 200: - resp_json = req.json() - - for show in resp_json: - if show not in processed_movies: - processed_movies.append(show) + return self._make_items_request( + url='https://api.trakt.tv/users/{authenticate_user}/watchlist/movies', + authenticate_user=authenticate_user, + limit=limit, + languages=languages, + object_name='movies', + type_name='watchlist from {authenticate_user}', + ) - # check if we have fetched the last page, break if so - if 'X-Pagination-Page-Count' not in req.headers or not int(req.headers['X-Pagination-Page-Count']): - log.debug("There was no more pages to retrieve") - break - elif payload['page'] >= int(req.headers['X-Pagination-Page-Count']): - log.debug("There are no more pages to retrieve results from") - break - else: - log.info("There are %d pages left to retrieve results from", - int(req.headers['X-Pagination-Page-Count']) - payload['page']) - payload['page'] += 1 - time.sleep(5) - elif req.status_code == 401: - log.error("The authentication to Trakt is revoked. Please re-authenticate.") - - exit() - else: - log.error("Failed to retrieve movies on watchlist from %s, request response: %d", authenticate_user, - req.status_code) - break - - if len(processed_movies): - log.debug("Found %d movies on watchlist from %s", len(processed_movies), authenticate_user) - - return processed_movies - return None - except Exception: - log.exception("Exception retrieving movies on watchlist") - return None - - @backoff.on_predicate(backoff.expo, lambda x: x is None, max_tries=4, on_backoff=backoff_handler) def get_user_list_movies(self, list_url, authenticate_user=None, limit=1000, languages=None): - try: - processed_movies = [] - - if languages is None: - languages = ['en'] - - # generate payload - payload = {'extended': 'full', 'limit': limit, 'page': 1} - if languages: - payload['languages'] = ','.join(languages) - - try: - import re - list_user = re.search('\/users\/([^/]*)', list_url).group(1) - list_key = re.search('\/lists\/([^/]*)', list_url).group(1) - except: - log.error('The URL "%s" is not in the correct format', list_url) - - log.debug('Fetching %s from %s', list_key, list_user) - - # make request - while True: - headers, authenticate_user = self.oauth_headers(authenticate_user) - - req = requests.get('https://api.trakt.tv/users/' + list_user + '/lists/' + list_key + '/items/movies', - params=payload, - headers=headers, - timeout=30) - log.debug("Request User: %s", authenticate_user) - log.debug("Request URL: %s", req.url) - log.debug("Request Payload: %s", payload) - log.debug("Response Code: %d", req.status_code) - log.debug("Response Page: %d of %d", payload['page'], - 0 if 'X-Pagination-Page-Count' not in req.headers else int( - req.headers['X-Pagination-Page-Count'])) - - if req.status_code == 200: - resp_json = req.json() - - for show in resp_json: - if show not in processed_movies: - processed_movies.append(show) - - # check if we have fetched the last page, break if so - if 'X-Pagination-Page-Count' not in req.headers or not int(req.headers['X-Pagination-Page-Count']): - log.debug("There was no more pages to retrieve") - break - elif payload['page'] >= int(req.headers['X-Pagination-Page-Count']): - log.debug("There are no more pages to retrieve results from") - break - else: - log.info("There are %d pages left to retrieve results from", - int(req.headers['X-Pagination-Page-Count']) - payload['page']) - payload['page'] += 1 - time.sleep(5) - elif req.status_code == 401: - log.error("The authentication to Trakt is revoked. Please re-authenticate.") - - exit() - else: - log.error("Failed to retrieve movies on %s from %s, request response: %d", list_key, - authenticate_user, - req.status_code) - break - - if len(processed_movies): - log.debug("Found %d movies on %s from %s", len(processed_movies), list_key, authenticate_user) - - return processed_movies - return None - except Exception: - log.exception("Exception retrieving movies on user list") - return None + list_user, list_key = extract_list_user_and_key_from_url(list_url) + + log.debug('Fetching %s from %s', list_key, list_user) + + return self._make_items_request( + url='https://api.trakt.tv/users/' + list_user + '/lists/' + list_key + '/items/movies', + authenticate_user=authenticate_user, + limit=limit, + languages=languages, + object_name='movies', + type_name=(list_key + ' from ' + list_user), + ) diff --git a/misc/config.py b/misc/config.py index 1f9dea2..f123139 100644 --- a/misc/config.py +++ b/misc/config.py @@ -60,6 +60,7 @@ class Config(object, metaclass=Singleton): 'blacklisted_genres': [], 'blacklisted_networks': [], 'allowed_countries': [], + 'allowed_languages': [], 'blacklisted_min_runtime': 15, 'blacklisted_min_year': 2000, 'blacklisted_max_year': 2019, @@ -72,7 +73,8 @@ class Config(object, metaclass=Singleton): 'blacklisted_max_year': 2019, 'blacklist_title_keywords': [], 'blacklisted_tmdb_ids': [], - 'allowed_countries': [] + 'allowed_countries': [], + 'allowed_languages': [] } }, 'automatic': { @@ -154,11 +156,6 @@ class Config(object, metaclass=Singleton): sub_upgraded = False merged = settings2.copy() - # print(settings1) - # print(settings2) - # print(overwrite) - # print("_______________") - if isinstance(settings1, dict): for k, v in settings1.items(): # missing k diff --git a/traktarr.py b/traktarr.py index 1f22dff..4718e74 100755 --- a/traktarr.py +++ b/traktarr.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import os.path +import signal import sys import time @@ -16,7 +17,7 @@ notify = None # Click @click.group(help='Add new shows & movies to Sonarr/Radarr from Trakt.') -@click.version_option('1.2.0', prog_name='traktarr') +@click.version_option('1.2.1', prog_name='traktarr') @click.option( '--config', envvar='TRAKTARR_CONFIG', @@ -62,13 +63,61 @@ def trakt_authentication(): from media.trakt import Trakt trakt = Trakt(cfg) - response = trakt.oauth_authentication() - - if response: + if trakt.oauth_authentication(): log.info("Authentication information saved; please restart the application") exit() +def validate_trakt(trakt, notifications): + if not trakt.validate_client_id(): + log.error("Aborting due to failure to validate Trakt API Key") + if notifications: + callback_notify({'event': 'error', 'reason': 'Failure to validate Trakt API Key'}) + exit() + else: + log.info("Validated Trakt API Key") + + +def validate_pvr(pvr, type, notifications): + if not pvr.validate_api_key(): + log.error("Aborting due to failure to validate %s URL / API Key", type) + if notifications: + callback_notify({'event': 'error', 'reason': 'Failure to validate %s URL / API Key' % type}) + return None + else: + log.info("Validated %s URL & API Key", type) + + +def get_profile_id(pvr, profile): + # retrieve profile id for requested profile + profile_id = pvr.get_profile_id(profile) + if not profile_id or not profile_id > 0: + log.error("Aborting due to failure to retrieve Profile ID for: %s", profile) + exit() + log.info("Retrieved Profile ID for %s: %d", profile, profile_id) + return profile_id + + +def get_profile_tags(pvr): + profile_tags = pvr.get_tags() + if profile_tags is None: + log.error("Aborting due to failure to retrieve Tag ID's") + exit() + log.info("Retrieved %d Tag ID's", len(profile_tags)) + return profile_tags + + +def get_objects(pvr, type, notifications): + objects_list = pvr.get_objects() + if not objects_list: + log.error("Aborting due to failure to retrieve %s shows list", type) + if notifications: + callback_notify({'event': 'error', 'reason': 'Failure to retrieve %s shows list' % type}) + exit() + log.info("Retrieved %s shows list, shows found: %d", type, len(objects_list)) + return objects_list + + ############################################################ # SHOWS ############################################################ @@ -80,42 +129,20 @@ def trakt_authentication(): def show(show_id, folder=None, no_search=False): from media.sonarr import Sonarr from media.trakt import Trakt - from misc import helpers + from helpers import sonarr as sonarr_helper # replace sonarr root_folder if folder is supplied if folder: cfg['sonarr']['root_folder'] = folder - # validate trakt api_key trakt = Trakt(cfg) - if not trakt.validate_client_id(): - log.error("Aborting due to failure to validate Trakt API Key") - return None - else: - log.info("Validated Trakt API Key") - - # validate sonarr url & api_key sonarr = Sonarr(cfg.sonarr.url, cfg.sonarr.api_key) - if not sonarr.validate_api_key(): - log.error("Aborting due to failure to validate Sonarr URL / API Key") - return None - else: - log.info("Validated Sonarr URL & API Key") - # retrieve profile id for requested profile - profile_id = sonarr.get_profile_id(cfg.sonarr.profile) - if not profile_id or not profile_id > 0: - log.error("Aborting due to failure to retrieve Profile ID for: %s", cfg.sonarr.profile) - return None - else: - log.info("Retrieved Profile ID for %s: %d", cfg.sonarr.profile, profile_id) + validate_trakt(trakt, False) + validate_pvr(sonarr, 'Sonarr', False) - # retrieve profile tags - profile_tags = sonarr.get_tags() - if profile_tags is None: - log.error("Aborting due to failure to retrieve Tag ID's") - else: - log.info("Retrieved %d Tag ID's", len(profile_tags)) + profile_id = get_profile_id(sonarr, cfg.sonarr.profile) + profile_tags = get_profile_tags(sonarr) # get trakt show trakt_show = trakt.get_show(show_id) @@ -128,17 +155,16 @@ def show(show_id, folder=None, no_search=False): trakt_show['year']) # determine which tags to use when adding this series - use_tags = helpers.sonarr_series_tag_id_from_network(profile_tags, cfg.sonarr.tags, - trakt_show['network']) + use_tags = sonarr_helper.series_tag_id_from_network(profile_tags, cfg.sonarr.tags, trakt_show['network']) # add show to sonarr if sonarr.add_series(trakt_show['ids']['tvdb'], trakt_show['title'], trakt_show['ids']['slug'], profile_id, cfg.sonarr.root_folder, use_tags, not no_search): log.info("ADDED %s (%d) with tags: %s", trakt_show['title'], trakt_show['year'], - helpers.sonarr_readable_tag_from_ids(profile_tags, use_tags)) + sonarr_helper.readable_tag_from_ids(profile_tags, use_tags)) else: log.error("FAILED adding %s (%d) with tags: %s", trakt_show['title'], trakt_show['year'], - helpers.sonarr_readable_tag_from_ids(profile_tags, use_tags)) + sonarr_helper.readable_tag_from_ids(profile_tags, use_tags)) return @@ -159,13 +185,15 @@ def shows(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_sea authenticate_user=None): from media.sonarr import Sonarr from media.trakt import Trakt - from misc import helpers + from helpers import misc as misc_helper + from helpers import sonarr as sonarr_helper + from helpers import trakt as trakt_helper added_shows = 0 # remove genre from shows blacklisted_genres if supplied - if genre and genre in cfg.filters.shows.blacklisted_genres: - cfg['filters']['shows']['blacklisted_genres'].remove(genre) + if genre: + misc_helper.unblacklist_genres(genre, cfg['filters']['shows']['blacklisted_genres']) # replace sonarr root_folder if folder is supplied if folder: @@ -173,73 +201,29 @@ def shows(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_sea # validate trakt client_id trakt = Trakt(cfg) - if not trakt.validate_client_id(): - log.error("Aborting due to failure to validate Trakt API Key") - if notifications: - callback_notify({'event': 'abort', 'type': 'shows', 'list_type': list_type, - 'reason': 'Failure to validate Trakt API Key'}) - return None - else: - log.info("Validated Trakt API Key") - - # validate sonarr url & api_key sonarr = Sonarr(cfg.sonarr.url, cfg.sonarr.api_key) - if not sonarr.validate_api_key(): - log.error("Aborting due to failure to validate Sonarr URL / API Key") - if notifications: - callback_notify({'event': 'abort', 'type': 'shows', 'list_type': list_type, - 'reason': 'Failure to validate Sonarr URL / API Key'}) - return None - else: - log.info("Validated Sonarr URL & API Key") - # retrieve profile id for requested profile - profile_id = sonarr.get_profile_id(cfg.sonarr.profile) - if not profile_id or not profile_id > 0: - log.error("Aborting due to failure to retrieve Profile ID for: %s", cfg.sonarr.profile) - if notifications: - callback_notify({'event': 'abort', 'type': 'shows', 'list_type': list_type, - 'reason': 'Failure to retrieve Sonarr Profile ID of %s' % cfg.sonarr.profile}) - return None - else: - log.info("Retrieved Profile ID for %s: %d", cfg.sonarr.profile, profile_id) + validate_trakt(trakt, notifications) + validate_pvr(sonarr, 'Sonarr', notifications) - # retrieve profile tags - profile_tags = sonarr.get_tags() - if profile_tags is None: - log.error("Aborting due to failure to retrieve Tag ID's") - if notifications: - callback_notify({'event': 'abort', 'type': 'shows', 'list_type': list_type, - 'reason': "Failure to retrieve Sonarr Tag ID's"}) - return None - else: - log.info("Retrieved %d Tag ID's", len(profile_tags)) + profile_id = get_profile_id(sonarr, cfg.sonarr.profile) + profile_tags = get_profile_tags(sonarr) - # get sonarr series list - sonarr_series_list = sonarr.get_series() - if not sonarr_series_list: - log.error("Aborting due to failure to retrieve Sonarr shows list") - if notifications: - callback_notify({'event': 'abort', 'type': 'shows', 'list_type': list_type, - 'reason': 'Failure to retrieve Sonarr shows list'}) - return None - else: - log.info("Retrieved Sonarr shows list, shows found: %d", len(sonarr_series_list)) + pvr_objects_list = get_objects(sonarr, 'Sonarr', notifications) # get trakt series list - trakt_series_list = None if list_type.lower() == 'anticipated': - trakt_series_list = trakt.get_anticipated_shows() + trakt_objects_list = trakt.get_anticipated_shows(genres=genre, languages=cfg.filters.shows.allowed_languages) elif list_type.lower() == 'trending': - trakt_series_list = trakt.get_trending_shows() + trakt_objects_list = trakt.get_trending_shows(genres=genre, languages=cfg.filters.shows.allowed_languages) elif list_type.lower() == 'popular': - trakt_series_list = trakt.get_popular_shows() + trakt_objects_list = trakt.get_popular_shows(genres=genre, languages=cfg.filters.shows.allowed_languages) elif list_type.lower() == 'watchlist': - trakt_series_list = trakt.get_watchlist_shows(authenticate_user) + trakt_objects_list = trakt.get_watchlist_shows(authenticate_user) else: - trakt_series_list = trakt.get_user_list_shows(list_type, authenticate_user) + trakt_objects_list = trakt.get_user_list_shows(list_type, authenticate_user) - if not trakt_series_list: + if not trakt_objects_list: log.error("Aborting due to failure to retrieve Trakt %s shows list", list_type) if notifications: callback_notify( @@ -247,10 +231,10 @@ def shows(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_sea 'reason': 'Failure to retrieve Trakt %s shows list' % list_type}) return None else: - log.info("Retrieved Trakt %s shows list, shows found: %d", list_type, len(trakt_series_list)) + log.info("Retrieved Trakt %s shows list, shows found: %d", list_type, len(trakt_objects_list)) # build filtered series list without series that exist in sonarr - processed_series_list = helpers.sonarr_remove_existing_series(sonarr_series_list, trakt_series_list) + processed_series_list = sonarr_helper.remove_existing_series(pvr_objects_list, trakt_objects_list) if processed_series_list is None: log.error("Aborting due to failure to remove existing Sonarr shows from retrieved Trakt shows list") if notifications: @@ -271,31 +255,31 @@ def shows(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_sea for series in sorted_series_list: try: # check if genre matches genre supplied via argument - if genre and genre.lower() not in series['show']['genres']: - log.debug("Skipping: %s because it was not from %s genre", series['show']['title'], genre.lower()) + if genre and not misc_helper.allowed_genres(genre, 'show', series): + log.debug("Skipping: %s because it was not from %s genre(s)", series['show']['title'], genre.lower()) continue # check if series passes out blacklist criteria inspection - if not helpers.trakt_is_show_blacklisted(series, cfg.filters.shows): + if not trakt_helper.is_show_blacklisted(series, cfg.filters.shows): log.info("Adding: %s | Genres: %s | Network: %s | Country: %s", series['show']['title'], ', '.join(series['show']['genres']), series['show']['network'], series['show']['country'].upper()) # determine which tags to use when adding this series - use_tags = helpers.sonarr_series_tag_id_from_network(profile_tags, cfg.sonarr.tags, - series['show']['network']) + use_tags = sonarr_helper.series_tag_id_from_network(profile_tags, cfg.sonarr.tags, + series['show']['network']) # add show to sonarr if sonarr.add_series(series['show']['ids']['tvdb'], series['show']['title'], series['show']['ids']['slug'], profile_id, cfg.sonarr.root_folder, use_tags, not no_search): log.info("ADDED %s (%d) with tags: %s", series['show']['title'], series['show']['year'], - helpers.sonarr_readable_tag_from_ids(profile_tags, use_tags)) + sonarr_helper.readable_tag_from_ids(profile_tags, use_tags)) if notifications: callback_notify({'event': 'add_show', 'list_type': list_type, 'show': series['show']}) added_shows += 1 else: log.error("FAILED adding %s (%d) with tags: %s", series['show']['title'], series['show']['year'], - helpers.sonarr_readable_tag_from_ids(profile_tags, use_tags)) + sonarr_helper.readable_tag_from_ids(profile_tags, use_tags)) # stop adding shows, if added_shows >= add_limit if add_limit and added_shows >= add_limit: @@ -334,26 +318,12 @@ def movie(movie_id, folder=None, no_search=False): # validate trakt api_key trakt = Trakt(cfg) - if not trakt.validate_client_id(): - log.error("Aborting due to failure to validate Trakt API Key") - return None - else: - log.info("Validated Trakt API Key") - - # validate radarr url & api_key radarr = Radarr(cfg.radarr.url, cfg.radarr.api_key) - if not radarr.validate_api_key(): - log.error("Aborting due to failure to validate Radarr URL / API Key") - return None - else: - log.info("Validated Radarr URL & API Key") - # retrieve profile id for requested profile - profile_id = radarr.get_profile_id(cfg.radarr.profile) - if not profile_id or not profile_id > 0: - log.error("Aborting due to failure to retrieve Profile ID for: %s", cfg.radarr.profile) - else: - log.info("Retrieved Profile ID for %s: %d", cfg.radarr.profile, profile_id) + validate_trakt(trakt, False) + validate_pvr(radarr, 'Radarr', False) + + profile_id = get_profile_id(radarr, cfg.radarr.profile) # get trakt movie trakt_movie = trakt.get_movie(movie_id) @@ -392,13 +362,15 @@ def movies(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_se authenticate_user=None): from media.radarr import Radarr from media.trakt import Trakt - from misc import helpers + from helpers import misc as misc_helper + from helpers import radarr as radarr_helper + from helpers import trakt as trakt_helper added_movies = 0 # remove genre from movies blacklisted_genres if supplied - if genre and genre in cfg.filters.movies.blacklisted_genres: - cfg['filters']['movies']['blacklisted_genres'].remove(genre) + if genre: + misc_helper.unblacklist_genres(genre, cfg['filters']['movies']['blacklisted_genres']) # replace radarr root_folder if folder is supplied if folder: @@ -406,65 +378,30 @@ def movies(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_se # validate trakt api_key trakt = Trakt(cfg) - if not trakt.validate_client_id(): - log.error("Aborting due to failure to validate Trakt API Key") - if notifications: - callback_notify({'event': 'abort', 'type': 'movies', 'list_type': list_type, - 'reason': 'Failure to validate Trakt API Key'}) - return None - else: - log.info("Validated Trakt API Key") - - # validate radarr url & api_key radarr = Radarr(cfg.radarr.url, cfg.radarr.api_key) - if not radarr.validate_api_key(): - log.error("Aborting due to failure to validate Radarr URL / API Key") - if notifications: - callback_notify( - {'event': 'abort', 'type': 'movies', 'list_type': list_type, - 'reason': 'Failure to validate Radarr URL / API Key'}) - return None - else: - log.info("Validated Radarr URL & API Key") - # retrieve profile id for requested profile - profile_id = radarr.get_profile_id(cfg.radarr.profile) - if not profile_id or not profile_id > 0: - log.error("Aborting due to failure to retrieve Profile ID for: %s", cfg.radarr.profile) - if notifications: - callback_notify({'event': 'abort', 'type': 'movies', 'list_type': list_type, - 'reason': 'Failure to retrieve Radarr Profile ID of %s' % cfg.radarr.profile}) - return None - else: - log.info("Retrieved Profile ID for %s: %d", cfg.radarr.profile, profile_id) + validate_trakt(trakt, notifications) + validate_pvr(radarr, 'Radarr', notifications) - # get radarr movies list - radarr_movie_list = radarr.get_movies() - if not radarr_movie_list: - log.error("Aborting due to failure to retrieve Radarr movies list") - if notifications: - callback_notify({'event': 'abort', 'type': 'movies', 'list_type': list_type, - 'reason': 'Failure to retrieve Radarr movies list'}) - return None - else: - log.info("Retrieved Radarr movies list, movies found: %d", len(radarr_movie_list)) + profile_id = get_profile_id(radarr, cfg.radarr.profile) + + pvr_objects_list = get_objects(radarr, 'Radarr', notifications) # get trakt movies list - trakt_movies_list = None if list_type.lower() == 'anticipated': - trakt_movies_list = trakt.get_anticipated_movies() + trakt_objects_list = trakt.get_anticipated_movies(genres=genre, languages=cfg.filters.movies.allowed_languages) elif list_type.lower() == 'trending': - trakt_movies_list = trakt.get_trending_movies() + trakt_objects_list = trakt.get_trending_movies(genres=genre, languages=cfg.filters.movies.allowed_languages) elif list_type.lower() == 'popular': - trakt_movies_list = trakt.get_popular_movies() + trakt_objects_list = trakt.get_popular_movies(genres=genre, languages=cfg.filters.movies.allowed_languages) elif list_type.lower() == 'boxoffice': - trakt_movies_list = trakt.get_boxoffice_movies() + trakt_objects_list = trakt.get_boxoffice_movies() elif list_type.lower() == 'watchlist': - trakt_movies_list = trakt.get_watchlist_movies(authenticate_user) + trakt_objects_list = trakt.get_watchlist_movies(authenticate_user) else: - trakt_movies_list = trakt.get_user_list_movies(list_type, authenticate_user) + trakt_objects_list = trakt.get_user_list_movies(list_type, authenticate_user) - if not trakt_movies_list: + if not trakt_objects_list: log.error("Aborting due to failure to retrieve Trakt %s movies list", list_type) if notifications: callback_notify( @@ -472,10 +409,10 @@ def movies(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_se 'reason': 'Failure to retrieve Trakt %s movies list' % list_type}) return None else: - log.info("Retrieved Trakt %s movies list, movies found: %d", list_type, len(trakt_movies_list)) + log.info("Retrieved Trakt %s movies list, movies found: %d", list_type, len(trakt_objects_list)) # build filtered movie list without movies that exist in radarr - processed_movies_list = helpers.radarr_remove_existing_movies(radarr_movie_list, trakt_movies_list) + processed_movies_list = radarr_helper.remove_existing_movies(pvr_objects_list, trakt_objects_list) if processed_movies_list is None: log.error("Aborting due to failure to remove existing Radarr movies from retrieved Trakt movies list") if notifications: @@ -496,12 +433,12 @@ def movies(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_se for movie in sorted_movies_list: try: # check if genre matches genre supplied via argument - if genre and genre.lower() not in movie['movie']['genres']: - log.debug("Skipping: %s because it was not from %s genre", movie['movie']['title'], genre.lower()) + if genre and not misc_helper.allowed_genres(genre, 'movie', movie): + log.debug("Skipping: %s because it was not from %s genre(s)", movie['movie']['title'], genre.lower()) continue # check if movie passes out blacklist criteria inspection - if not helpers.trakt_is_movie_blacklisted(movie, cfg.filters.movies): + if not trakt_helper.is_movie_blacklisted(movie, cfg.filters.movies): log.info("Adding: %s (%d) | Genres: %s | Country: %s", movie['movie']['title'], movie['movie']['year'], ', '.join(movie['movie']['genres']), movie['movie']['country'].upper()) # add movie to radarr @@ -554,6 +491,9 @@ def callback_notify(data): elif data['event'] == 'abort': notify.send(message="Aborted adding Trakt %s %s due to: %s" % (data['list_type'], data['type'], data['reason'])) return + elif data['event'] == 'error': + notify.send(message="Error: %s" % data['reason']) + return else: log.error("Unexpected callback: %s", data) return @@ -705,28 +645,34 @@ def automatic_movies(add_delay=2.5, no_search=False, notifications=False): @click.option('--add-delay', '-d', default=2.5, help='Seconds between each add request to Sonarr / Radarr.', show_default=True) @click.option('--no-search', is_flag=True, help='Disable search when adding to Sonarr / Radarr.') +@click.option('--run-now', is_flag=True, help="Do a first run immediately without waiting.") @click.option('--no-notifications', is_flag=True, help="Disable notifications.") -def run(add_delay=2.5, no_search=False, no_notifications=False): +def run(add_delay=2.5, no_search=False, run_now=False, no_notifications=False): log.info("Automatic mode is now running...") - # Add tasks to schedule and do first run + # Add tasks to schedule and do first run if enabled if cfg.automatic.movies.interval: - schedule.every(cfg.automatic.movies.interval).hours.do( + movie_schedule = schedule.every(cfg.automatic.movies.interval).hours.do( automatic_movies, add_delay, no_search, not no_notifications - ).run() + ) + if run_now: + movie_schedule.run() + # Sleep between tasks time.sleep(add_delay) if cfg.automatic.shows.interval: - schedule.every(cfg.automatic.shows.interval).hours.do( + shows_schedule = schedule.every(cfg.automatic.shows.interval).hours.do( automatic_shows, add_delay, no_search, not no_notifications - ).run() + ) + if run_now: + shows_schedule.run() # Enter running schedule while True: @@ -758,13 +704,19 @@ def init_notifications(): return +# Handles exit signals, cancels jobs and exits cleanly +def exit_handler(signum, frame): + log.info("Received %s, canceling jobs and exiting.", signal.Signals(signum).name) + schedule.clear() + exit() + + ############################################################ # MAIN ############################################################ if __name__ == "__main__": print(""" - ,--. ,--. ,--. ,-' '-.,--.--. ,--,--.| |,-.,-' '-. ,--,--.,--.--.,--.--. '-. .-'| .--'' ,-. || /'-. .-'' ,-. || .--'| .--' @@ -779,5 +731,11 @@ if __name__ == "__main__": ######################################################################### # GNU General Public License v3.0 # ######################################################################### - """) +""") + + # Register the signal handlers + signal.signal(signal.SIGTERM, exit_handler) + signal.signal(signal.SIGINT, exit_handler) + + # Start application app()