From e176d51f263c4837b8ce26124095ad8064159693 Mon Sep 17 00:00:00 2001 From: l3uddz Date: Tue, 6 Mar 2018 19:14:00 +0000 Subject: [PATCH] initial commit --- .gitignore | 30 ++++ README.md | 2 + media/__init__.py | 1 + media/radarr.py | 109 +++++++++++++ media/sonarr.py | 110 ++++++++++++++ media/trakt.py | 379 ++++++++++++++++++++++++++++++++++++++++++++++ misc/__init__.py | 2 + misc/config.py | 101 ++++++++++++ misc/helpers.py | 313 ++++++++++++++++++++++++++++++++++++++ misc/log.py | 50 ++++++ misc/str.py | 15 ++ requirements.txt | 4 + traktarr.py | 229 ++++++++++++++++++++++++++++ 13 files changed, 1345 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 media/__init__.py create mode 100644 media/radarr.py create mode 100644 media/sonarr.py create mode 100644 media/trakt.py create mode 100644 misc/__init__.py create mode 100644 misc/config.py create mode 100644 misc/helpers.py create mode 100644 misc/log.py create mode 100644 misc/str.py create mode 100644 requirements.txt create mode 100644 traktarr.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2bb030c --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# User-specific stuff: +.idea + +## File-based project format: +*.iws + +# IntelliJ +/out/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +*.pyc + +# logs +*.log* + +# databases +*.db + +# configs +*.cfg +*.json + +# generators +*.bat + +# Pyenv +**/.python-version diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa4c1a2 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# traktarr +Script to add new series & movies to Sonarr/Radarr based on Trakt lists. \ No newline at end of file diff --git a/media/__init__.py b/media/__init__.py new file mode 100644 index 0000000..cac681c --- /dev/null +++ b/media/__init__.py @@ -0,0 +1 @@ +from media import trakt, sonarr, radarr \ No newline at end of file diff --git a/media/radarr.py b/media/radarr.py new file mode 100644 index 0000000..ca9015e --- /dev/null +++ b/media/radarr.py @@ -0,0 +1,109 @@ +from urllib.parse import urljoin + +import backoff +import requests + +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(urljoin(self.server_url, 'api/system/status'), headers=self.headers, timeout=30) + 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(urljoin(self.server_url, 'api/movie'), headers=self.headers, timeout=30) + 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(urljoin(self.server_url, 'api/profile'), headers=self.headers, timeout=30) + 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 + + @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, 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': '', + 'addOptions': {'ignoreEpisodesWithFiles': False, 'ignoreEpisodesWithoutFiles': False, + 'searchForMovie': search_missing} + } + + # make request + req = requests.post(urljoin(self.server_url, 'api/movie'), json=payload, headers=self.headers, timeout=30) + log.debug("Request URL: %s", req.url) + log.debug("Request Payload: %s", payload) + log.debug("Request Response: %d", req.status_code) + + if req.status_code == 201 and req.json()['tmdbId'] == movie_tmdbid: + log.debug("Successfully added %s (%d)", movie_title, movie_tmdbid) + return True + elif req.status_code == 401: + log.error("Failed to add %s (%d), reason: %s", movie_title, movie_tmdbid, + req.json()['errorMessage'] if '{' in req.text else "\n{}".format(req.text)) + 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 diff --git a/media/sonarr.py b/media/sonarr.py new file mode 100644 index 0000000..38b41c1 --- /dev/null +++ b/media/sonarr.py @@ -0,0 +1,110 @@ +from urllib.parse import urljoin + +import backoff +import requests + +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 = { + '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(urljoin(self.server_url, 'api/system/status'), headers=self.headers, timeout=30) + 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(urljoin(self.server_url, 'api/series'), headers=self.headers, timeout=30) + 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 series", len(resp_json)) + return resp_json + else: + log.error("Failed to retrieve all series, request response: %d", req.status_code) + except Exception: + log.exception("Exception retrieving series: ") + 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(urljoin(self.server_url, 'api/profile'), headers=self.headers, timeout=30) + 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 + + @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, profile_id, root_folder, search_missing=False): + try: + # generate payload + payload = { + 'tvdbId': series_tvdbid, 'title': series_title, + 'qualityProfileId': profile_id, 'images': [], + 'seasons': [], 'seasonFolder': True, + 'monitored': True, 'rootFolderPath': root_folder, + 'addOptions': {'ignoreEpisodesWithFiles': False, + 'ignoreEpisodesWithoutFiles': False, + 'searchForMissingEpisodes': search_missing} + } + + # make request + req = requests.post(urljoin(self.server_url, 'api/series'), json=payload, headers=self.headers, timeout=30) + log.debug("Request URL: %s", req.url) + log.debug("Request Payload: %s", payload) + log.debug("Request Response: %d", req.status_code) + + if req.status_code == 201 and req.json()['tvdbId'] == series_tvdbid: + log.debug("Successfully added %s (%d)", series_title, series_tvdbid) + return True + elif req.status_code == 401: + log.error("Failed to add %s (%d), reason: %s", series_title, series_tvdbid, + req.json()['errorMessage'] if '{' in req.text else "\n{}".format(req.text)) + 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 series %s (%d): ", series_title, series_tvdbid) + return None diff --git a/media/trakt.py b/media/trakt.py new file mode 100644 index 0000000..a164352 --- /dev/null +++ b/media/trakt.py @@ -0,0 +1,379 @@ +import backoff +import requests + +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: + def __init__(self, api_key): + self.api_key = api_key + self.headers = { + 'Content-Type': 'application/json', + 'trakt-api-version': '2', + 'trakt-api-key': self.api_key + } + + def validate_api_key(self): + try: + # request trending shows to determine if api_key is valid + payload = {'extended': 'full', 'limit': 1000} + + # make request + req = requests.get('https://api.trakt.tv/shows/anticipated', params=payload, headers=self.headers, + timeout=30) + log.debug("Request Response: %d", req.status_code) + + if req.status_code == 200: + return True + return False + except Exception: + log.exception("Exception validating api_key: ") + return False + + ############################################################ + # Shows + ############################################################ + + @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 = [] + + 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/anticipated', params=payload, headers=self.headers, + 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 + + else: + log.error("Failed to retrieve anticipated shows, request response: %d", req.status_code) + break + + 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 + + @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', params=payload, headers=self.headers, + 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 + + 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', params=payload, headers=self.headers, + 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 + + 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 + + ############################################################ + # Movies + ############################################################ + + @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', params=payload, headers=self.headers, + 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 + + 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', params=payload, headers=self.headers, + 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 + + 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', params=payload, headers=self.headers, + 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 movie in resp_json: + if movie not in processed_movies: + processed_movies.append({'movie': 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 + + 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 diff --git a/misc/__init__.py b/misc/__init__.py new file mode 100644 index 0000000..537ee21 --- /dev/null +++ b/misc/__init__.py @@ -0,0 +1,2 @@ +from misc import config, str, helpers +from misc.log import logger diff --git a/misc/config.py b/misc/config.py new file mode 100644 index 0000000..b9a72b6 --- /dev/null +++ b/misc/config.py @@ -0,0 +1,101 @@ +import json +import os +import sys + +from attrdict import AttrDict + +config_path = os.path.join(os.path.dirname(sys.argv[0]), 'config.json') +base_config = { + 'core': { + 'debug': False + }, + 'trakt': { + 'api_key': '' + }, + 'sonarr': { + 'url': 'http://localhost:8989', + 'api_key': '', + 'profile': 'WEBDL-1080p', + 'root_folder': '/tv/' + }, + 'radarr': { + 'url': 'http://localhost:8989', + 'api_key': '', + 'profile': 'Remux', + 'root_folder': '/movies/' + }, + 'filters': { + 'shows': { + 'blacklisted_genres': ['animation', 'game-show', 'talk-show', 'home-and-garden', 'children', 'reality', + 'anime', 'news', 'documentary', 'special-interest'], + 'blacklisted_networks': ['twitch', 'youtube', 'nickelodeon', 'hallmark', 'reelzchannel', 'disney', + 'cnn', 'cbbc', 'the movie network', 'teletoon', 'cartoon network', 'espn', + 'yahoo!', + 'fox sports'], + 'allowed_countries': ['us', 'gb', 'ca'], + 'blacklisted_min_runtime': 15, + 'blacklisted_min_year': 2000 + }, + 'movies': { + 'blacklisted_genres': ['documentary', 'music'], + 'blacklisted_min_runtime': 60, + 'blacklisted_min_year': 2000, + 'blacklist_title_keywords': ['untitled', 'barbie'], + 'allowed_countries': ['us', 'gb', 'ca'] + } + } +} +cfg = None + + +class AttrConfig(AttrDict): + """ + Simple AttrDict subclass to return None when requested attribute does not exist + """ + + def __init__(self, config): + super().__init__(config) + + def __getattr__(self, item): + try: + return super().__getattr__(item) + except AttributeError: + pass + # Default behaviour + return None + + +def build_config(): + if not os.path.exists(config_path): + print("Dumping default config to: %s" % config_path) + with open(config_path, 'w') as fp: + json.dump(base_config, fp, sort_keys=True, indent=2) + return True + else: + return False + + +def dump_config(): + if os.path.exists(config_path): + with open(config_path, 'w') as fp: + json.dump(cfg, fp, sort_keys=True, indent=2) + return True + else: + return False + + +def load_config(): + with open(config_path, 'r') as fp: + return AttrConfig(json.load(fp)) + + +############################################################ +# LOAD CFG +############################################################ + +# dump/load config +if build_config(): + print("Please edit the default configuration before running again!") + exit(0) +else: + cfg = load_config() diff --git a/misc/helpers.py b/misc/helpers.py new file mode 100644 index 0000000..991215e --- /dev/null +++ b/misc/helpers.py @@ -0,0 +1,313 @@ +from misc import str as misc_str +from misc.log import logger + +log = logger.get_logger(__name__) + + +############################################################ +# SONARR +############################################################ + +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 series: %s", tmp['title']) + continue + series[tmp['tvdbId']] = tmp + return series + except Exception: + log.exception("Exception processing sonarr series 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 series from trakt list: ") + return None + + +def trakt_blacklisted_show_genre(show, genres): + blacklisted = False + try: + if not show['show']['genres']: + log.debug("%s was blacklisted because it had no genres", show['show']['title']) + blacklisted = True + else: + for genre in genres: + if genre.lower() in show['show']['genres']: + log.debug("%s was blacklisted because it has genre: %s", show['show']['title'], genre) + blacklisted = True + break + + except Exception: + log.exception("Exception determining if show has a blacklisted genre %s: ", show) + return blacklisted + + +def trakt_blacklisted_show_year(show, earliest_year): + blacklisted = False + try: + year = misc_str.get_year_from_timestamp(show['show']['first_aired']) + if not year: + log.debug("%s was blacklisted due to having an unknown first_aired date", show['show']['title']) + blacklisted = True + else: + if year < earliest_year: + log.debug("%s was blacklisted because it first aired in: %d", show['show']['title'], year) + blacklisted = True + except Exception: + log.exception("Exception determining if show is before earliest_year %s:", show) + return blacklisted + + +def trakt_blacklisted_show_country(show, allowed_countries): + blacklisted = False + try: + if not show['show']['country']: + log.debug("%s was blacklisted because it had no country", show['show']['title']) + blacklisted = True + else: + if show['show']['country'].lower() not in allowed_countries: + log.debug("%s was blacklisted because it's from country: %s", show['show']['title'], + show['show']['country']) + blacklisted = True + + except Exception: + log.exception("Exception determining if show was from an allowed country %s: ", show) + return blacklisted + + +def trakt_blacklisted_show_network(show, networks): + blacklisted = False + try: + if not show['show']['network']: + log.debug("%s was blacklisted because it had no network", show['show']['title']) + blacklisted = True + else: + for network in networks: + if network.lower() in show['show']['network'].lower(): + log.debug("%s was blacklisted because it's from network: %s", show['show']['title'], + show['show']['network']) + blacklisted = True + break + + except Exception: + log.exception("Exception determining if show is from a blacklisted network %s: ", show) + return blacklisted + + +def trakt_blacklisted_show_runtime(show, lowest_runtime): + blacklisted = False + try: + if not show['show']['runtime'] or not isinstance(show['show']['runtime'], int): + log.debug("%s was blacklisted because it had no runtime", show['show']['title']) + blacklisted = True + elif int(show['show']['runtime']) < lowest_runtime: + log.debug("%s was blacklisted because it had a runtime of: %d", show['show']['title'], + show['movie']['runtime']) + blacklisted = True + + except Exception: + log.exception("Exception determining if show had sufficient runtime %s: ", show) + return blacklisted + + +def trakt_is_show_blacklisted(show, blacklist_settings): + blacklisted = False + try: + if trakt_blacklisted_show_year(show, blacklist_settings.blacklisted_min_year): + blacklisted = True + if trakt_blacklisted_show_country(show, blacklist_settings.allowed_countries): + blacklisted = True + if trakt_blacklisted_show_genre(show, blacklist_settings.blacklisted_genres): + blacklisted = True + if trakt_blacklisted_show_network(show, blacklist_settings.blacklisted_networks): + blacklisted = True + if trakt_blacklisted_show_runtime(show, blacklist_settings.blacklisted_min_runtime): + 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): + blacklisted = False + try: + if not movie['movie']['genres']: + log.debug("%s was blacklisted because it had no genres", movie['movie']['title']) + blacklisted = True + else: + for genre in genres: + if genre.lower() in movie['movie']['genres']: + log.debug("%s was blacklisted because it has genre: %s", movie['movie']['title'], genre) + blacklisted = True + break + + except Exception: + log.exception("Exception determining if movie has a blacklisted genre %s: ", movie) + return blacklisted + + +def trakt_blacklisted_movie_year(movie, earliest_year): + blacklisted = False + try: + year = movie['movie']['year'] + if year is None or not isinstance(year, int): + log.debug("%s was blacklisted due to having an unknown year", movie['movie']['title']) + blacklisted = True + else: + if int(year) < earliest_year: + log.debug("%s was blacklisted because it's year is: %d", movie['movie']['title'], int(year)) + blacklisted = True + except Exception: + log.exception("Exception determining if movie is before earliest_year %s:", movie) + return blacklisted + + +def trakt_blacklisted_movie_country(movie, allowed_countries): + blacklisted = False + try: + if not movie['movie']['country']: + log.debug("%s was blacklisted because it had no country", movie['movie']['title']) + blacklisted = True + else: + if movie['movie']['country'].lower() not in allowed_countries: + log.debug("%s was blacklisted because it's from country: %s", movie['movie']['title'], + movie['movie']['country']) + blacklisted = True + + except Exception: + log.exception("Exception determining if movie was from an allowed country %s: ", movie) + return blacklisted + + +def trakt_blacklisted_movie_title(movie, blacklisted_keywords): + blacklisted = False + try: + if not movie['movie']['title']: + log.debug("Blacklisted movie because it had no title: %s", movie) + blacklisted = True + else: + for keyword in blacklisted_keywords: + if keyword.lower() in movie['movie']['title'].lower(): + log.debug("%s was blacklisted because it had title keyword: %s", movie['movie']['title'], keyword) + blacklisted = True + break + + except Exception: + log.exception("Exception determining if movie had a blacklisted title %s: ", movie) + return blacklisted + + +def trakt_blacklisted_movie_runtime(movie, lowest_runtime): + blacklisted = False + try: + if not movie['movie']['runtime'] or not isinstance(movie['movie']['runtime'], int): + log.debug("%s was blacklisted because it had no runtime", movie['movie']['title']) + blacklisted = True + elif int(movie['movie']['runtime']) < lowest_runtime: + log.debug("%s was blacklisted because it had a runtime of: %d", movie['movie']['title'], + movie['movie']['runtime']) + blacklisted = True + + except Exception: + log.exception("Exception determining if movie had sufficient runtime %s: ", movie) + return blacklisted + + +def trakt_is_movie_blacklisted(movie, blacklist_settings): + blacklisted = False + try: + if trakt_blacklisted_movie_title(movie, blacklist_settings.blacklist_title_keywords): + blacklisted = True + if trakt_blacklisted_movie_year(movie, blacklist_settings.blacklisted_min_year): + blacklisted = True + if trakt_blacklisted_movie_country(movie, blacklist_settings.allowed_countries): + blacklisted = True + if trakt_blacklisted_movie_genre(movie, blacklist_settings.blacklisted_genres): + blacklisted = True + if trakt_blacklisted_movie_runtime(movie, blacklist_settings.blacklisted_min_runtime): + blacklisted = True + except Exception: + log.exception("Exception determining if movie was blacklisted %s: ", movie) + return blacklisted diff --git a/misc/log.py b/misc/log.py new file mode 100644 index 0000000..251724f --- /dev/null +++ b/misc/log.py @@ -0,0 +1,50 @@ +import logging +import os +import sys +from logging.handlers import RotatingFileHandler + +from misc.config import cfg + + +class Logger: + def __init__(self, file_name=None, log_level=logging.DEBUG, + log_format='%(asctime)s - %(levelname)-10s - %(name)-35s - %(funcName)-35s - %(message)s'): + self.log_format = log_format + + # init root_logger + self.log_formatter = logging.Formatter(log_format) + self.root_logger = logging.getLogger() + self.root_logger.setLevel(log_level) + + # disable bloat loggers + logging.getLogger('urllib3').setLevel(logging.ERROR) + + # init console_logger + self.console_handler = logging.StreamHandler(sys.stdout) + self.console_handler.setFormatter(self.log_formatter) + self.root_logger.addHandler(self.console_handler) + + # init file_logger + if file_name: + if os.path.sep not in file_name: + # file_name was a filename, lets build a full file_path + self.log_file_path = os.path.join(os.path.dirname(sys.argv[0]), file_name) + else: + self.log_file_path = file_name + + self.file_handler = RotatingFileHandler( + self.log_file_path, + maxBytes=1024 * 1024 * 5, + backupCount=5 + ) + self.file_handler.setFormatter(self.log_formatter) + self.root_logger.addHandler(self.file_handler) + + # Set chosen logging level + self.root_logger.setLevel(log_level) + + def get_logger(self, name): + return self.root_logger.getChild(name) + + +logger = Logger('activity.log', logging.DEBUG if cfg.core.debug else logging.INFO) diff --git a/misc/str.py b/misc/str.py new file mode 100644 index 0000000..f47425c --- /dev/null +++ b/misc/str.py @@ -0,0 +1,15 @@ +from misc.log import logger + +log = logger.get_logger(__name__) + + +def get_year_from_timestamp(timestamp): + year = 0 + try: + if not timestamp: + return 0 + + year = timestamp[:timestamp.index('-')] + except Exception: + log.exception("Exception parsing year from %s: ", timestamp) + return int(year) if str(year).isdigit() else 0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cd28427 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +attrdict==2.0.0 +click==6.7 +backoff==1.4.3 +requests==2.18.4 diff --git a/traktarr.py b/traktarr.py new file mode 100644 index 0000000..8418edd --- /dev/null +++ b/traktarr.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +import time + +import click + +from media.radarr import Radarr +from media.sonarr import Sonarr +from media.trakt import Trakt +from misc import helpers +from misc.config import cfg +from misc.log import logger + +############################################################ +# INIT +############################################################ + +# Logging +log = logger.get_logger('traktarr') + + +# Click +@click.group(help='Add new series/movies to Sonarr & Radarr from Trakt.') +def app(): + pass + + +############################################################ +# SHOWS +############################################################ + +@app.command(help='Add new series to Sonarr.') +@click.option('--list-type', '-t', type=click.Choice(['anticipated', 'trending', 'popular']), + help='Trakt list to process.', required=True) +@click.option('--add-limit', '-l', default=0, help='Limit number of series added to Sonarr.', show_default=True) +@click.option('--add-delay', '-d', default=2.5, help='Seconds between each add request to Sonarr.', show_default=True) +def shows(list_type, add_limit=0, add_delay=2.5): + added_shows = 0 + + # validate trakt api_key + trakt = Trakt(cfg.trakt.api_key) + if not trakt.validate_api_key(): + log.error("Aborting due to failure to validate Trakt API Key") + return + 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 + 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 + else: + log.info("Retrieved Profile ID for %s: %d", cfg.sonarr.profile, profile_id) + + # get sonarr series list + sonarr_series_list = sonarr.get_series() + if not sonarr_series_list: + log.error("Aborting due to failure to retrieve Sonarr series list") + return + else: + log.info("Retrieved Sonarr series list, series found: %d", len(sonarr_series_list)) + + # get trakt series list + trakt_series_list = None + if list_type.lower() == 'anticipated': + trakt_series_list = trakt.get_anticipated_shows() + elif list_type.lower() == 'trending': + trakt_series_list = trakt.get_trending_shows() + elif list_type.lower() == 'popular': + trakt_series_list = trakt.get_popular_shows() + else: + log.error("Aborting due to unknown Trakt list type") + return + if not trakt_series_list: + log.error("Aborting due to failure to retrieve Trakt %s series list", list_type) + return + else: + log.info("Retrieved Trakt %s series list, series found: %d", list_type, len(trakt_series_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) + if not processed_series_list: + log.error("Aborting due to failure to remove existing Sonarr series from retrieved Trakt series list") + return + else: + log.info("Removed existing Sonarr series from Trakt series list, series left to process: %d", + len(processed_series_list)) + + # loop series_list + log.info("Processing list now...") + for series in processed_series_list: + try: + # check if series passes out blacklist criteria inspection + if not helpers.trakt_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()) + + # add show to sonarr + if sonarr.add_series(series['show']['ids']['tvdb'], series['show']['title'], profile_id, + cfg.sonarr.root_folder, + True): + log.info("ADDED %s (%d)", series['show']['title'], series['show']['year']) + added_shows += 1 + else: + log.error("FAILED adding %s (%d)", series['show']['title'], series['show']['year']) + + # stop adding shows, if added_shows >= add_limit + if add_limit and added_shows >= add_limit: + break + + # sleep before adding any more + time.sleep(add_delay) + + except Exception: + log.exception("Exception while processing series %s: ", series['show']['title']) + + log.info("Added %d new shows to Sonarr", added_shows) + + +@app.command(help='Add new movies to Radarr.') +@click.option('--list-type', '-t', type=click.Choice(['anticipated', 'trending', 'popular']), + help='Trakt list to process.', required=True) +@click.option('--add-limit', '-l', default=0, help='Limit number of movies added to Radarr.', show_default=True) +@click.option('--add-delay', '-d', default=2.5, help='Seconds between each add request to Radarr.', show_default=True) +def movies(list_type, add_limit=0, add_delay=2.5): + added_movies = 0 + + # validate trakt api_key + trakt = Trakt(cfg.trakt.api_key) + if not trakt.validate_api_key(): + log.error("Aborting due to failure to validate Trakt API Key") + return + 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 + 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) + return + else: + log.info("Retrieved Profile ID for %s: %d", cfg.radarr.profile, profile_id) + + # 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") + return + else: + log.info("Retrieved Radarr movies list, movies found: %d", len(radarr_movie_list)) + + # get trakt movies list + trakt_movies_list = None + if list_type.lower() == 'anticipated': + trakt_movies_list = trakt.get_anticipated_movies() + elif list_type.lower() == 'trending': + trakt_movies_list = trakt.get_trending_movies() + elif list_type.lower() == 'popular': + trakt_movies_list = trakt.get_popular_movies() + else: + log.error("Aborting due to unknown Trakt list type") + return + if not trakt_movies_list: + log.error("Aborting due to failure to retrieve Trakt %s movies list", list_type) + return + else: + log.info("Retrieved Trakt %s movies list, movies found: %d", list_type, len(trakt_movies_list)) + + # build filtered series list without series that exist in sonarr + processed_movies_list = helpers.radarr_remove_existing_movies(radarr_movie_list, trakt_movies_list) + if not processed_movies_list: + log.error("Aborting due to failure to remove existing Radarr movies from retrieved Trakt movies list") + return + else: + log.info("Removed existing Radarr movies from Trakt movies list, movies left to process: %d", + len(processed_movies_list)) + + # loop movies + log.info("Processing list now...") + for movie in processed_movies_list: + try: + # check if movie passes out blacklist criteria inspection + if not helpers.trakt_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 + if radarr.add_movie(movie['movie']['ids']['tmdb'], movie['movie']['title'], movie['movie']['year'], + profile_id, cfg.sonarr.root_folder, True): + log.info("ADDED %s (%d)", movie['movie']['title'], movie['movie']['year']) + added_movies += 1 + else: + log.error("FAILED adding %s (%d)", movie['movie']['title'], movie['movie']['year']) + + # stop adding movies, if added_movies >= add_limit + if add_limit and added_movies >= add_limit: + break + + # sleep before adding any more + time.sleep(add_delay) + + except Exception: + log.exception("Exception while processing movie %s: ", movie['movie']['title']) + + log.info("Added %d new movies to Radarr", added_movies) + + +############################################################ +# MAIN +############################################################ + +if __name__ == "__main__": + app()