diff --git a/README.md b/README.md index 1b05a17..02fdcb3 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Trakt lists currently supported: - popular - trending +Furthermore, watchlist and other types of list from multiple users are supported. + # Requirements 1. Python 3.5 or higher (`sudo apt install python3 python3-pip`). @@ -28,7 +30,38 @@ Install Traktarr to be run with `traktarr` command. 7. `traktarr` - run once to generate a default a config.json file. 8. `nano config.json` - edit preferences. -## 2. Setup Schedule +## 2. Authenticate Trakt (optional) + +If you want to acces private lists (watchlists or other user lists), +you'll need to authenticate Traktarr to access your personal lists. + +1. Create an 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 Redirecht uri field. +4. Click "Save app" +5. Open the Traktarr configuration file `config.json` and insert the +Client ID in the api_key and the Client Secret in the api_secret. Like this: +``` + { + "trakt": { + "api_key": "my_client_id", + "api_secret": "my_client_secret_key" + } + } +``` + +Repeat the following steps for every user you want to authenticate: +1. Run `traktarr trakt_authenticate` (from the installation path) +2. Go to https://trakt.tv/activate. +3. Enter the code you see in your terminal +4. Click continue +5. If you're not loggedin to Trakt, login noe +6. Accept + +You've now authenticated the user. +You can repeat this process for as many users as you want. + +## 3. Setup Schedule To have Traktarr get Movies and Shows for you automatically, on set interval. @@ -52,13 +85,25 @@ To have Traktarr get Movies and Shows for you automatically, on set interval. "boxoffice": 10, "interval": 24, "popular": 3, - "trending": 2 + "trending": 2, + "watchlist": { + "username": 10 + }, + "my-custom-list" { + "username": 10 + } }, "shows": { "anticipated": 10, "interval": 48, "popular": 1, - "trending": 2 + "trending": 2, + "watchlist": { + "username": 10 + }, + "my-custom-list" { + "username": 10 + } } }, "filters": { @@ -149,7 +194,8 @@ To have Traktarr get Movies and Shows for you automatically, on set interval. "url": "http://localhost:8989/" }, "trakt": { - "api_key": "" + "api_key": "", + "api_secret": "" } } ``` @@ -179,13 +225,33 @@ Movies can be run on a separate schedule from Shows. "boxoffice": 10, "interval": 24, "popular": 3, - "trending": 2 + "trending": 2, + "watchlist": { + "user1": 10 + "user2": 10 + }, + "my-custom-list": { + "user1": 10 + }, + "another-custom-list": { + "user2": 10 + } }, "shows": { "anticipated": 10, "interval": 48, "popular": 1, - "trending": 2 + "trending": 2, + "watchlist": { + "user1": 10 + "user2": 10 + }, + "my-custom-list": { + "user1": 10 + }, + "another-custom-list": { + "user2": 10 + } } }, ``` @@ -194,6 +260,10 @@ Movies can be run on a separate schedule from Shows. `anticipated`, `popular`, `trending`, `boxoffice` (movies only) - specify how many items from each Trakt list to find. +`watchlist` - add every user you want to fetch items from and specify how many items to fetch + +You can add every (private) list you want by adding the list key. + ## Filters Use filters to specify the movie/shows's country of origin or blacklist (i.e. filter-out) certain keywords, genres, years, runtime, or specific movies/shows. @@ -437,14 +507,7 @@ Finally, we will edit the Traktarr config and assign the `AMZN` tag to certain n `api_key` - Fill in your Trakt API key (_Client ID_). - -How to get a Trakt API Key: - - Go to https://trakt.tv/oauth/applications/new - - Fill in: - - Name: `Traktarr` - - Redirect uri: `https://google.com` - - Click `Save App` - - Retrieve the _Client ID_. +`api_secret` - Fill in your Trakt Secret key (_Client Scret_) # Usage @@ -467,9 +530,10 @@ Options: --help Show this message and exit. Commands: - movies Add new movies to Radarr. - run Run in automatic mode. - shows Add new shows to Sonarr. + movies Add new movies to Radarr. + run Run in automatic mode. + shows Add new shows to Sonarr. + trakt_authentication Authenticate Traktrarr to index your personal... ``` @@ -486,8 +550,9 @@ Usage: traktarr movies [OPTIONS] Add new movies to Radarr. Options: - -t, --list-type [anticipated|trending|popular|boxoffice] - Trakt list to process. [required] + -t, --list-type TEXT Trakt list to process. For example, anticipated, + trending, popular, boxoffice, watchlist or any + other user list [required] -l, --add-limit INTEGER Limit number of movies added to Radarr. [default: 0] -d, --add-delay FLOAT Seconds between each add request to Radarr. @@ -496,6 +561,8 @@ Options: -f, --folder TEXT Add movies with this root folder to Radarr. --no-search Disable search when adding movies to Radarr. --notifications Send notifications. + --user TEXT Specify which user to use for the personal Trakt + lists. Default: first user in the config --help Show this message and exit. ``` @@ -508,8 +575,9 @@ Usage: traktarr shows [OPTIONS] Add new shows to Sonarr. Options: - -t, --list-type [anticipated|trending|popular] - Trakt list to process. [required] + -t, --list-type TEXT Trakt list to process. For example, anticipated, + trending, popular, watchlist or any other user + list [required] -l, --add-limit INTEGER Limit number of shows added to Sonarr. [default: 0] -d, --add-delay FLOAT Seconds between each add request to Sonarr. @@ -518,6 +586,8 @@ Options: -f, --folder TEXT Add shows with this root folder to Sonarr. --no-search Disable search when adding shows to Sonarr. --notifications Send notifications. + --user TEXT Specify which user to use for the personal Trakt + lists. Default: first user in the config --help Show this message and exit. ``` diff --git a/media/trakt.py b/media/trakt.py index b2cf3c4..15a8670 100644 --- a/media/trakt.py +++ b/media/trakt.py @@ -15,8 +15,12 @@ def backoff_handler(details): class Trakt: - def __init__(self, api_key): - self.api_key = api_key + non_user_lists = ['anticipated', 'trending', 'popular', 'boxoffice'] + + def __init__(self, cfg): + self.cfg = cfg + self.api_key = cfg.trakt.api_key + self.api_secret = cfg.trakt.api_secret self.headers = { 'Content-Type': 'application/json', 'trakt-api-version': '2', @@ -44,6 +48,142 @@ class Trakt: log.exception("Exception validating api_key: ") return False + ############################################################ + # OAuth Authentication Initialisation + ############################################################ + + 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.api_key} + + # Request device code + req = requests.post('https://api.trakt.tv/oauth/device/code', params=payload, headers=self.headers) + device_code_response = req.json() + + # Display needed information to the user + log.info('Go to: %s on any device and enter %s. We\'ll be polling Trakt every %s seconds for a reply', + device_code_response['verification_url'], device_code_response['user_code'], + device_code_response['interval']) + + return device_code_response + + def __oauth_process_token_request(self, req): + success = False + + if req.status_code == 200: + # Success; saving the access token + access_token_response = req.json() + 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['Authorization'] = 'Bearer ' + access_token + + req = requests.get('https://api.trakt.tv/users/me', headers=temp_headers) + + from misc.config import Config + new_config = Config() + + new_config.merge_settings({ + "trakt": { + req.json()['username']: access_token_response + } + }) + + success = True + elif req.status_code == 404: + log.debug('The device code was wrong') + log.error('Whoops, something went wrong; aborting the authentication process') + elif req.status_code == 409: + log.error('You\'ve already authenticated this application; aborting the authentication process') + elif req.status_code == 410: + log.error('The authentication process has expired; please start again') + elif req.status_code == 418: + log.error('You\'ve denied the authentication; are you sure? Please try again') + elif req.status_code == 429: + log.debug('We\'re polling too quickly.') + + return success, req.status_code + + def __oauth_poll_for_access_token(self, device_code, polling_interval=5, polling_expire=600): + polling_start = time.time() + time.sleep(polling_interval) + tries = 0 + + while time.time() - polling_start < polling_expire: + tries += 1 + + 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.api_key, 'client_secret': self.api_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) + + success, status_code = self.__oauth_process_token_request(req) + + if success: + break + elif status_code == 426: + log.debug('Increasing the interval by one second') + polling_interval += 1 + + time.sleep(polling_interval) + return False + + def __oauth_refresh_access_token(self, refresh_token): + # TODO Doesn't work + + payload = {'refresh_token': refresh_token, 'client_id': self.api_key, 'client_secret': self.api_secret, + 'grant_type': 'refresh_token'} + + req = requests.post('https://api.trakt.tv/oauth/token', params=payload, headers=self.headers) + + success, status_code = self.__oauth_process_token_request(req) + + return success + + def oauth_authentication(self): + try: + device_code_response = self.__oauth_request_device_code() + + if self.__oauth_poll_for_access_token(device_code_response['device_code'], + device_code_response['interval'], + device_code_response['expires_in']): + return True + except Exception: + 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'] + users.pop('api_key') + users.pop('api_secret') + + user = list(users.keys())[0] + + 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 + ############################################################ # Shows ############################################################ @@ -108,6 +248,133 @@ class Trakt: 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_watchlist_shows(self, 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: + req = requests.get('https://api.trakt.tv/sync/watchlist/shows', params=payload, + headers=self.oauth_headers(user), + timeout=30) + log.debug("Request User: %s", 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 watchlist from %s, request response: %d", user, + req.status_code) + break + + if len(processed_shows): + log.debug("Found %d shows on watchlist from %s", len(processed_shows), 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_id, 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: + req = requests.get('https://api.trakt.tv/users/' + user + '/lists/' + list_id + '/items/shows', + params=payload, + headers=self.oauth_headers(user), + timeout=30) + log.debug("Request User: %s", 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 watchlist from %s, request response: %d", user, + req.status_code) + break + + if len(processed_shows): + log.debug("Found %d shows on watchlist from %s", len(processed_shows), 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_trending_shows(self, limit=1000, languages=None): try: @@ -473,3 +740,130 @@ class Trakt: 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, 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: + req = requests.get('https://api.trakt.tv/sync/watchlist/movies', params=payload, + headers=self.oauth_headers(user), + timeout=30) + log.debug("Request User: %s", 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 watchlist from %s, request response: %d", user, + req.status_code) + break + + if len(processed_movies): + log.debug("Found %d movies on watchlist from %s", len(processed_movies), 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_id, 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: + req = requests.get('https://api.trakt.tv/users/' + user + '/lists/' + list_id + '/items/movies', + params=payload, + headers=self.oauth_headers(user), + timeout=30) + log.debug("Request User: %s", 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 watchlist from %s, request response: %d", user, + req.status_code) + break + + if len(processed_movies): + log.debug("Found %d movies on watchlist from %s", len(processed_movies), user) + + return processed_movies + return None + except Exception: + log.exception("Exception retrieving movies on watchlist") + return None diff --git a/misc/config.py b/misc/config.py index 37d00ca..e1b1982 100644 --- a/misc/config.py +++ b/misc/config.py @@ -33,7 +33,6 @@ class AttrConfig(AttrDict): class Config(object, metaclass=Singleton): - base_config = { 'core': { 'debug': False @@ -150,36 +149,55 @@ class Config(object, metaclass=Singleton): with open(self.config_path, 'r') as fp: return AttrConfig(json.load(fp)) + def __inner_upgrade(self, settings1, settings2, key=None, overwrite=False): + 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 + if k not in settings2: + merged[k] = v + sub_upgraded = True + if not key: + print("Added %r config option: %s" % (str(k), str(v))) + else: + print("Added %r to config option %r: %s" % (str(k), str(key), str(v))) + continue + + # iterate children + if isinstance(v, dict) or isinstance(v, list): + merged[k], did_upgrade = self.__inner_upgrade(settings1[k], settings2[k], key=k, + overwrite=overwrite) + sub_upgraded = did_upgrade if did_upgrade else sub_upgraded + elif settings1[k] != settings2[k] and overwrite: + merged = settings1 + sub_upgraded = True + elif isinstance(settings1, list) and key: + for v in settings1: + if v not in settings2: + merged.append(v) + sub_upgraded = True + print("Added to config option %r: %s" % (str(key), str(v))) + continue + + return merged, sub_upgraded + def upgrade_settings(self, currents): - upgraded = False - - def inner_upgrade(default, current, key=None): - sub_upgraded = False - merged = current.copy() - if isinstance(default, dict): - for k, v in default.items(): - # missing k - if k not in current: - merged[k] = v - sub_upgraded = True - if not key: - print("Added %r config option: %s" % (str(k), str(v))) - else: - print("Added %r to config option %r: %s" % (str(k), str(key), str(v))) - continue - # iterate children - if isinstance(v, dict) or isinstance(v, list): - merged[k], did_upgrade = inner_upgrade(default[k], current[k], key=k) - sub_upgraded = did_upgrade if did_upgrade else sub_upgraded - - elif isinstance(default, list) and key: - for v in default: - if v not in current: - merged.append(v) - sub_upgraded = True - print("Added to config option %r: %s" % (str(key), str(v))) - continue - return merged, sub_upgraded - - upgraded_settings, upgraded = inner_upgrade(self.base_config, currents) + upgraded_settings, upgraded = self.__inner_upgrade(self.base_config, currents) + return AttrConfig(upgraded_settings), upgraded + + def merge_settings(self, settings_to_merge): + upgraded_settings, upgraded = self.__inner_upgrade(settings_to_merge, self.conf, overwrite=True) + + self.conf = upgraded_settings + + if upgraded: + self.dump_config() + return AttrConfig(upgraded_settings), upgraded diff --git a/traktarr.py b/traktarr.py index bfd5d0b..4115f47 100755 --- a/traktarr.py +++ b/traktarr.py @@ -53,20 +53,40 @@ def app(config, logfile): init_notifications() +############################################################ +# Trakt OAuth +############################################################ + +@app.command(help='Authenticate Traktrarr to index your personal lists') +def trakt_authentication(): + from media.trakt import Trakt + trakt = Trakt(cfg) + + response = trakt.oauth_authentication() + + if response: + log.info("Authentication information saved; please restart the application") + exit() + + ############################################################ # SHOWS ############################################################ @app.command(help='Add new shows to Sonarr.') -@click.option('--list-type', '-t', type=click.Choice(['anticipated', 'trending', 'popular']), - help='Trakt list to process.', required=True) +@click.option('--list-type', '-t', + help='Trakt list to process. For example, anticipated, trending, popular, watchlist or any other user list', + required=True) @click.option('--add-limit', '-l', default=0, help='Limit number of shows 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) @click.option('--genre', '-g', default=None, help='Only add shows from this genre to Sonarr.') @click.option('--folder', '-f', default=None, help='Add shows with this root folder to Sonarr.') @click.option('--no-search', is_flag=True, help='Disable search when adding shows to Sonarr.') @click.option('--notifications', is_flag=True, help='Send notifications.') -def shows(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_search=False, notifications=False): +@click.option('--user', + help='Specify which user to use for the personal Trakt lists. Default: first user in the config') +def shows(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_search=False, notifications=False, + user=None): from media.sonarr import Sonarr from media.trakt import Trakt from misc import helpers @@ -82,7 +102,7 @@ def shows(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_sea cfg['sonarr']['root_folder'] = folder # validate trakt api_key - trakt = Trakt(cfg.trakt.api_key) + trakt = Trakt(cfg) if not trakt.validate_api_key(): log.error("Aborting due to failure to validate Trakt API Key") if notifications: @@ -144,12 +164,11 @@ def shows(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_sea trakt_series_list = trakt.get_trending_shows() elif list_type.lower() == 'popular': trakt_series_list = trakt.get_popular_shows() + elif list_type.lower() == 'watchlist': + trakt_series_list = trakt.get_watchlist_shows(user) else: - log.error("Aborting due to unknown Trakt list type") - if notifications: - callback_notify({'event': 'abort', 'type': 'shows', 'list_type': list_type, - 'reason': 'Failure to determine Trakt list type'}) - return None + trakt_series_list = trakt.get_user_list_shows(list_type, user) + if not trakt_series_list: log.error("Aborting due to failure to retrieve Trakt %s shows list", list_type) if notifications: @@ -232,15 +251,19 @@ def shows(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_sea ############################################################ @app.command(help='Add new movies to Radarr.') -@click.option('--list-type', '-t', type=click.Choice(['anticipated', 'trending', 'popular', 'boxoffice']), - help='Trakt list to process.', required=True) +@click.option('--list-type', '-t', + help='Trakt list to process. For example, anticipated, trending, popular, boxoffice, watchlist or any other user list', + 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) @click.option('--genre', '-g', default=None, help='Only add movies from this genre to Radarr.') @click.option('--folder', '-f', default=None, help='Add movies with this root folder to Radarr.') @click.option('--no-search', is_flag=True, help='Disable search when adding movies to Radarr.') @click.option('--notifications', is_flag=True, help='Send notifications.') -def movies(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_search=False, notifications=False): +@click.option('--user', + help='Specify which user to use for the personal Trakt lists. Default: first user in the config') +def movies(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_search=False, notifications=False, + user=None): from media.radarr import Radarr from media.trakt import Trakt from misc import helpers @@ -256,7 +279,7 @@ def movies(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_se cfg['radarr']['root_folder'] = folder # validate trakt api_key - trakt = Trakt(cfg.trakt.api_key) + trakt = Trakt(cfg) if not trakt.validate_api_key(): log.error("Aborting due to failure to validate Trakt API Key") if notifications: @@ -310,12 +333,11 @@ def movies(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_se trakt_movies_list = trakt.get_popular_movies() elif list_type.lower() == 'boxoffice': trakt_movies_list = trakt.get_boxoffice_movies() + elif list_type.lower() == 'watchlist': + trakt_movies_list = trakt.get_watchlist_shows(user) else: - log.error("Aborting due to unknown Trakt list type") - if notifications: - callback_notify({'event': 'abort', 'type': 'movies', 'list_type': list_type, - 'reason': 'Failure to determine Trakt list type'}) - return None + trakt_movies_list = trakt.get_user_list_shows(list_type, user) + if not trakt_movies_list: log.error("Aborting due to failure to retrieve Trakt %s movies list", list_type) if notifications: @@ -412,23 +434,42 @@ def callback_notify(data): def automatic_shows(add_delay=2.5, no_search=False, notifications=False): + from media.trakt import Trakt + total_shows_added = 0 try: log.info("Started") - for list_type, type_amount in cfg.automatic.shows.items(): + for list_type, value in cfg.automatic.shows.items(): if list_type.lower() == 'interval': continue - elif type_amount <= 0: - log.info("Skipped Trakt's %s shows list", list_type) - continue + + if list_type.lower() not in Trakt.non_user_lists: + type_amount = value + + if type_amount <= 0: + log.info("Skipped Trakt's %s shows list", list_type) + continue + else: + log.info("Adding %d shows from Trakt's %s list", type_amount, list_type) + + # run shows + added_shows = shows.callback(list_type=list_type, add_limit=type_amount, + add_delay=add_delay, no_search=no_search, + notifications=notifications) else: - log.info("Adding %d shows from Trakt's %s list", type_amount, list_type) + for user, type_amount in value: + if type_amount <= 0: + log.info("Skipped Trakt's %s for &s", list_type, user) + continue + else: + log.info("Adding %d shows from the %s from &s", type_amount, list_type, user) + + # run shows + added_shows = shows.callback(list_type=list_type, add_limit=type_amount, + add_delay=add_delay, no_search=no_search, + notifications=notifications, user=user) - # run shows - added_shows = shows.callback(list_type=list_type, add_limit=type_amount, - add_delay=add_delay, no_search=no_search, - notifications=notifications) if added_shows is None: log.error("Failed adding shows from Trakt's %s list", list_type) time.sleep(10) @@ -449,23 +490,42 @@ def automatic_shows(add_delay=2.5, no_search=False, notifications=False): def automatic_movies(add_delay=2.5, no_search=False, notifications=False): + from media.trakt import Trakt + total_movies_added = 0 try: log.info("Started") - for list_type, type_amount in cfg.automatic.movies.items(): + for list_type, value in cfg.automatic.movies.items(): if list_type.lower() == 'interval': continue - elif type_amount <= 0: - log.info("Skipped Trakt's %s movies list", list_type) - continue + + if list_type.lower() not in Trakt.non_user_lists: + type_amount = value + + if type_amount <= 0: + log.info("Skipped Trakt's %s movies list", list_type) + continue + else: + log.info("Adding %d movies from Trakt's %s list", type_amount, list_type) + + # run movies + added_movies = movies.callback(list_type=list_type, add_limit=type_amount, + add_delay=add_delay, no_search=no_search, + notifications=notifications) else: - log.info("Adding %d movies from Trakt's %s list", type_amount, list_type) + for user, type_amount in value: + if type_amount <= 0: + log.info("Skipped Trakt's %s for &s", list_type, user) + continue + else: + log.info("Adding %d movies from the %s from &s", type_amount, list_type, user) + + # run movies + added_movies = movies.callback(list_type=list_type, add_limit=type_amount, + add_delay=add_delay, no_search=no_search, + notifications=notifications, user=user) - # run movies - added_movies = movies.callback(list_type=list_type, add_limit=type_amount, - add_delay=add_delay, no_search=no_search, - notifications=notifications) if added_movies is None: log.error("Failed adding movies from Trakt's %s list", list_type) time.sleep(10)