diff --git a/.gitignore b/.gitignore index 4c20ccf..45b27b2 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,4 @@ __pycache__/ build/ dist/ *.manifest -*.spec \ No newline at end of file +*.spec diff --git a/README.md b/README.md index 1b05a17..94f5e0f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,35 @@ -# Traktarr -Script to add new shows & movies to Sonarr/Radarr based on Trakt lists. +# traktarr +Add new shows & movies to Sonarr/Radarr from Trakt. + +Types of Trakt lists supported: + +- Official Trakt lists + + - Trending + + - Popular + + - Anticipated + + - boxoffice + +- Public lists + +- Private lists* + + - Watchlist + + - Custom list(s) + +\* Support for multiple (authenticated) users. + + +# Demo + +Click to enlarge. + +[![asciicast](assets/demo.gif)](https://asciinema.org/a/180044) -Trakt lists currently supported: -- anticipated -- boxoffice -- interval -- popular -- trending # Requirements @@ -17,7 +40,7 @@ Trakt lists currently supported: ## 1. Base Install -Install Traktarr to be run with `traktarr` command. +Install traktarr to be run with `traktarr` command. 1. `cd /opt` 2. `sudo git clone https://github.com/l3uddz/traktarr` @@ -25,22 +48,53 @@ Install Traktarr to be run with `traktarr` command. 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 default a config.json file. +7. `traktarr` - run once to generate a sample a config.json file. 8. `nano config.json` - edit preferences. -## 2. Setup Schedule +## 2. Create a Trakt Application -To have Traktarr get Movies and Shows for you automatically, on set interval. +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: -1. `sudo cp /opt/traktarr/systemd/traktarr.service /etc/systemd/system/` -2. `sudo systemctl daemon-reload` -3. `sudo systemctl enable traktarr.service` -4. `sudo systemctl start traktarr.service` + ``` + { + "trakt": { + "client_id": "my_client_id", + "client_secret": "my_client_secret_key" + } + } + ``` + +## 3. Authenticate User(s) (optional) + +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: + + ``` + - We're talking to Trakt to get your verification code. Please wait a moment... + - 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. # Configuration +## Sample Configuration + ```json { "core": { @@ -133,7 +187,7 @@ To have Traktarr get Movies and Shows for you automatically, on set interval. "service": "slack", "webhook_url": "" }, - "verbose": true, + "verbose": true }, "radarr": { "api_key": "", @@ -149,7 +203,8 @@ To have Traktarr get Movies and Shows for you automatically, on set interval. "url": "http://localhost:8989/" }, "trakt": { - "api_key": "" + "client_id": "", + "client_secret": "" } } ``` @@ -168,9 +223,11 @@ To have Traktarr get Movies and Shows for you automatically, on set interval. ## Automatic -Used for automatic / scheduled Traktarr tasks. +Used for automatic / scheduled traktarr tasks. -Movies can be run on a separate schedule from Shows. +Movies can be run on a separate schedule then from Shows. + +_Note: These settings are only needed if you plan to use traktarr on a schedule (i.e. via manual/CLI command only); see [Usage](#usage)._ ```json "automatic": { @@ -179,21 +236,128 @@ Movies can be run on a separate schedule from Shows. "boxoffice": 10, "interval": 24, "popular": 3, - "trending": 2 + "trending": 2, + "watchlist": {}, + "lists": {} }, "shows": { "anticipated": 10, "interval": 48, "popular": 1, - "trending": 2 + "trending": 2, + "watchlist": {}, + "lists": {} } }, ``` -`interval` - specify how often (in hours) to run Traktarr task. +`interval` - specify how often (in hours) to run traktarr task. `anticipated`, `popular`, `trending`, `boxoffice` (movies only) - specify how many items from each Trakt list to find. +`watchlist` - specify which watchlists to fetch (see explanation below) + +`lists` - specify which custom lists to fetch (see explanation below) + +### Personal Watchlists + +The watchlist task can be scheduled with a differtent item limit for every (authenticated) user. + + +So for every user, you will add: `"username": limit` to the watchlist key. For example: + +```json +"automatic": { + "movies": { + "watchlist": { + "user1": 10, + "user2": 5 + } + }, + "shows": { + "watchlist": { + "user1": 2, + "user3": 1 + } + } +}, +``` + +Of course you can combine this with running the other list types as well. + +### Custom Lists + +You can also schedule any number of public or private custom lists. + +For both public and private lists you'll need the url to that list. When viewing the list on Trakt, simply copy the url from the address bar of the your browser. + +#### Public Lists + +Public lists can be added by specifying the url and the item limit like this: + +```json +"automatic": { + "movies": { + "lists": { + "https://trakt.tv/users/rkerwin/lists/top-100-movies": 10 + } + }, + "shows": { + "lists": { + "https://trakt.tv/users/claireaa/lists/top-100-tv-shows-of-all-time-ign": 10 + } + } +}, +``` + + +#### Private Lists + +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 + } + } +}, +``` + +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)._ + +```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 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. @@ -227,7 +391,7 @@ Use filters to specify the movie/shows's country of origin or blacklist (i.e. fi `blacklist_title_keywords` - blacklist certain words in titles. -`blacklisted_genres` - blacklist certain generes. +`blacklisted_genres` - blacklist certain genres. `blacklisted_max_year` - blacklist release dates after specified year. @@ -283,7 +447,7 @@ Use filters to specify the movie/shows's country of origin or blacklist (i.e. fi `allowed_countries` - allowed countries of origin. -`blacklisted_genres` - blacklist certain generes. +`blacklisted_genres` - blacklist certain genres. `blacklisted_max_year` - blacklist release dates after specified year. @@ -298,9 +462,11 @@ Use filters to specify the movie/shows's country of origin or blacklist (i.e. fi ## Notifications -Notification alerts during tasks. +Notification alerts for traktarr tasks. -Currently, only Pushover and Slack are supported. More will abe added later. +_Note: Manual commands need the `--notifications` flag._ + +Currently, only Pushover and Slack are supported. More will be added later. ```json @@ -314,7 +480,7 @@ Currently, only Pushover and Slack are supported. More will abe added later. "service": "slack", "webhook_url": "" }, - "verbose": true, + "verbose": true }, ``` @@ -387,6 +553,8 @@ Sonarr configuration. To show how tags work, we will create a sample tag `AMZN` and assign it to certain networks. +_Note: These are optional._ + ### Sonarr First, we will create a tag in Sonarr (Settings > Indexers > Restrictions). @@ -397,9 +565,9 @@ Must not contain: Tags: AMZN ``` -### Traktarr +### traktarr -Finally, we will edit the Traktarr config and assign the `AMZN` tag to certain networks. +Finally, we will edit the traktarr config and assign the `AMZN` tag to certain networks. ```json "tags": { @@ -429,27 +597,35 @@ Finally, we will edit the Traktarr config and assign the `AMZN` tag to certain n ## Trakt +Trakt Authentication info: + ```json "trakt": { - "api_key": "" + "client_id": "", + "client_secret": "" } ``` -`api_key` - Fill in your Trakt API key (_Client ID_). +`client_id` - Fill in your Trakt API key (_Client ID_). +`client_secret` - Fill in your Trakt Secret key (_Client Scret_) -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_. # Usage +## Automatic (Scheduled) + +To have traktarr get Movies and Shows for you automatically, on set interval. -## General +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` + +## Manual (CLI) + +### General ``` traktarr @@ -458,7 +634,7 @@ traktarr ``` Usage: traktarr [OPTIONS] COMMAND [ARGS]... - Add new shows & movies to Sonarr/Radarr from Trakt lists. + Add new shows & movies to Sonarr/Radarr from Trakt. Options: --version Show the version and exit. @@ -467,13 +643,35 @@ 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. + movie Add a single movie to Radarr. + movies Add multiple movies to Radarr. + run Run in automatic mode. + show Add a single show to Sonarr. + shows Add multiple shows to Sonarr. + trakt_authentication Authenticate traktarr. ``` +### Movie (Single Movie) + +``` +traktarr movie --help +``` + +``` +Usage: traktarr movie [OPTIONS] + + Add a single movie to Radarr. -## Movies +Options: + -id, --movie_id TEXT Trakt movie_id. [required] + -f, --folder TEXT Add movie with this root folder to Radarr. + --no-search Disable search when adding movie to Radarr. + --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._ + +### Movies (Multiple Movies) ``` traktarr movies --help @@ -483,52 +681,118 @@ traktarr movies --help ``` Usage: traktarr movies [OPTIONS] - Add new movies to Radarr. + Add multiple movies to Radarr. Options: - -t, --list-type [anticipated|trending|popular|boxoffice] - Trakt list to process. [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. - [default: 2.5] - -g, --genre TEXT Only add movies from this genre to Radarr. - -f, --folder TEXT Add movies with this root folder to Radarr. - --no-search Disable search when adding movies to Radarr. - --notifications Send notifications. - --help Show this message and exit. + -t, --list-type TEXT Trakt list to process. For example, anticipated, + trending, popular, boxoffice, watchlist or any URL + to a 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. + [default: 2.5] + -g, --genre TEXT Only add movies from this genre to Radarr. + -f, --folder TEXT Add movies with this root folder to Radarr. + --no-search Disable search when adding movies to Radarr. + --notifications Send notifications. + --authenticate-user TEXT Specify which user to authenticate with to + retrieve Trakt lists. Default: first user in the + config. + --help Show this message and exit. ``` +### Show (Single Show) + +``` +traktarr show --help +``` -## Shows ``` -Usage: traktarr shows [OPTIONS] +Usage: traktarr show [OPTIONS] - Add new shows to Sonarr. + Add a single show to Sonarr. Options: - -t, --list-type [anticipated|trending|popular] - Trakt list to process. [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. - [default: 2.5] - -g, --genre TEXT Only add shows from this genre to Sonarr. - -f, --folder TEXT Add shows with this root folder to Sonarr. - --no-search Disable search when adding shows to Sonarr. - --notifications Send notifications. - --help Show this message and exit. + -id, --show_id TEXT Trakt show_id. [required] + -f, --folder TEXT Add show with this root folder to Sonarr. + --no-search Disable search when adding show to Sonarr. + --help Show this message and exit. ``` -## Examples +_Note: This command only works with `-id` or `--show_id` specified (i.e. not with lists), and support both Trakt IDs and IMDB IDs._ -``` -traktarr movies -t boxoffice -g comedy -l 10 --notifications +### Shows (Multiple Shows) ``` +traktarr shows --help +``` + ``` -traktarr shows -t popular -l 2 --no-search +Usage: traktarr shows [OPTIONS] + + Add multiple shows to Sonarr. + +Options: + -t, --list-type TEXT Trakt list to process. For example, anticipated, + trending, popular, watchlist or any URL to a 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. + [default: 2.5] + -g, --genre TEXT Only add shows from this genre to Sonarr. + -f, --folder TEXT Add shows with this root folder to Sonarr. + --no-search Disable search when adding shows to Sonarr. + --notifications Send notifications. + --authenticate-user TEXT Specify which user to authenticate with to + retrieve Trakt lists. Default: first user in the + config + --help Show this message and exit. ``` + +## Examples (Manual) + +- Add the movie "Black Panther (2018)": + + ``` + traktarr movie -id black-panther-2018 + ``` + +- Add the show "The 100": + + ``` + traktarr show -id the-100 + ``` + +- Add boxoffice movies, labeled with the comedy genre, limited to 10 items, and send notifications: + + ``` + traktarr movies -t boxoffice -g comedy -l 10 --notifications + ``` + +- Add popular shows, limited to 2 items, and don't start the search in Sonarr: + + ``` + traktarr shows -t popular -l 2 --no-search + ``` + +- Add all shows from the watchlist of `user1`: + + ``` + traktarr shows -t watchlist --authenticate-user user1 + ``` + +- Add all movies from the public list `https://trakt.tv/users/rkerwin/lists/top-100-movies`: + + ``` + traktarr movies -t https://trakt.tv/users/rkerwin/lists/top-100-movies + ``` + +- Add all movies from the private list `https://trakt.tv/users/user1/lists/private-movies-list` of `user1`: + + ``` + traktarr movies -t https://trakt.tv/users/user1/lists/private-movies-list --authenticate-user=user1 + ``` diff --git a/assets/demo.gif b/assets/demo.gif new file mode 100644 index 0000000..1f7925c Binary files /dev/null and b/assets/demo.gif differ diff --git a/media/trakt.py b/media/trakt.py index b2cf3c4..cb46a0e 100644 --- a/media/trakt.py +++ b/media/trakt.py @@ -15,17 +15,21 @@ 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.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.api_key + 'trakt-api-key': self.client_id } - def validate_api_key(self): + def validate_client_id(self): try: - # request trending shows to determine if api_key is valid + # request trending shows to determine if client_id is valid payload = {'extended': 'full', 'limit': 1000} # make request @@ -41,13 +45,195 @@ class Trakt: return True return False except Exception: - log.exception("Exception validating api_key: ") + log.exception("Exception validating client_id: ") 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.client_id} + + # 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.client_id, 'client_secret': self.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) + + 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): + payload = {'refresh_token': refresh_token, 'client_id': self.client_id, 'client_secret': self.client_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'] + + if 'client_id' in users.keys(): + users.pop('client_id') + + if 'client_secret' in users.keys(): + users.pop('client_secret') + + if len(users) > 0: + user = list(users.keys())[0] + + 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) + + exit() + + # If there is no default user, try without authentication + if user is None: + log.info('Using no authentication') + + return headers, 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 + + 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_anticipated_shows(self, limit=1000, languages=None): try: @@ -108,6 +294,149 @@ 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, 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'])) + + 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 = [] + + 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) + + 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: @@ -233,6 +562,34 @@ class Trakt: # 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: @@ -473,3 +830,145 @@ 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, 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) + + # 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 diff --git a/misc/config.py b/misc/config.py index 37d00ca..1f9dea2 100644 --- a/misc/config.py +++ b/misc/config.py @@ -33,13 +33,13 @@ class AttrConfig(AttrDict): class Config(object, metaclass=Singleton): - base_config = { 'core': { 'debug': False }, 'trakt': { - 'api_key': '' + 'client_id': '', + 'client_secret': '' }, 'sonarr': { 'url': 'http://localhost:8989/', @@ -150,36 +150,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..1f22dff 100755 --- a/traktarr.py +++ b/traktarr.py @@ -15,8 +15,8 @@ notify = None # Click -@click.group(help='Add new shows & movies to Sonarr/Radarr from Trakt lists.') -@click.version_option('1.1.3', prog_name='traktarr') +@click.group(help='Add new shows & movies to Sonarr/Radarr from Trakt.') +@click.version_option('1.2.0', prog_name='traktarr') @click.option( '--config', envvar='TRAKTARR_CONFIG', @@ -53,20 +53,110 @@ def app(config, logfile): init_notifications() +############################################################ +# Trakt OAuth +############################################################ + +@app.command(help='Authenticate traktarr.') +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) +@app.command(help='Add a single show to Sonarr.') +@click.option('--show_id', '-id', help='Trakt show_id.', required=True) +@click.option('--folder', '-f', default=None, help='Add show with this root folder to Sonarr.') +@click.option('--no-search', is_flag=True, help='Disable search when adding show to Sonarr.') +def show(show_id, folder=None, no_search=False): + from media.sonarr import Sonarr + from media.trakt import Trakt + from misc import helpers + + # 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) + + # 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)) + + # get trakt show + trakt_show = trakt.get_show(show_id) + + if not trakt_show: + log.error("Aborting due to failure to retrieve Trakt show") + return None + else: + log.info("Retrieved Trakt show information for %s: %s (%d)", show_id, trakt_show['title'], + 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']) + + # 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)) + 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)) + + return + + +@app.command(help='Add multiple shows to Sonarr.') +@click.option('--list-type', '-t', + help='Trakt list to process. For example, anticipated, trending, popular, watchlist or any URL to a 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('--authenticate-user', + help='Specify which user to authenticate with to retrieve 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, + authenticate_user=None): from media.sonarr import Sonarr from media.trakt import Trakt from misc import helpers @@ -81,9 +171,9 @@ def shows(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_sea if folder: cfg['sonarr']['root_folder'] = folder - # validate trakt api_key - trakt = Trakt(cfg.trakt.api_key) - if not trakt.validate_api_key(): + # 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, @@ -144,12 +234,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(authenticate_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, authenticate_user) + if not trakt_series_list: log.error("Aborting due to failure to retrieve Trakt %s shows list", list_type) if notifications: @@ -231,16 +320,76 @@ def shows(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_sea # MOVIES ############################################################ -@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) +@app.command(help='Add a single movie to Radarr.') +@click.option('--movie_id', '-id', help='Trakt movie_id.', required=True) +@click.option('--folder', '-f', default=None, help='Add movie with this root folder to Radarr.') +@click.option('--no-search', is_flag=True, help='Disable search when adding movie to Radarr.') +def movie(movie_id, folder=None, no_search=False): + from media.radarr import Radarr + from media.trakt import Trakt + + # replace radarr root_folder if folder is supplied + if folder: + cfg['radarr']['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 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) + + # get trakt movie + trakt_movie = trakt.get_movie(movie_id) + + if not trakt_movie: + log.error("Aborting due to failure to retrieve Trakt movie") + return None + else: + log.info("Retrieved Trakt movie information for %s: %s (%d)", movie_id, trakt_movie['title'], + trakt_movie['year']) + + # add movie to radarr + if radarr.add_movie(trakt_movie['ids']['tmdb'], trakt_movie['title'], trakt_movie['year'], + trakt_movie['ids']['slug'], profile_id, cfg.radarr.root_folder, not no_search): + log.info("ADDED %s (%d)", trakt_movie['title'], trakt_movie['year']) + else: + log.error("FAILED adding %s (%d)", trakt_movie['title'], trakt_movie['year']) + + return + + +@app.command(help='Add multiple movies to Radarr.') +@click.option('--list-type', '-t', + help='Trakt list to process. For example, anticipated, trending, popular, boxoffice, watchlist ' + 'or any URL to a 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('--authenticate-user', + help='Specify which user to authenticate with to retrieve 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, + authenticate_user=None): from media.radarr import Radarr from media.trakt import Trakt from misc import helpers @@ -256,8 +405,8 @@ 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) - if not trakt.validate_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, @@ -310,12 +459,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_movies(authenticate_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_movies(list_type, authenticate_user) + if not trakt_movies_list: log.error("Aborting due to failure to retrieve Trakt %s movies list", list_type) if notifications: @@ -412,23 +560,57 @@ 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(): + added_shows = None + if list_type.lower() == 'interval': continue - elif 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) + if list_type.lower() in Trakt.non_user_lists: + limit = value + + if limit <= 0: + log.info("Skipped Trakt's %s shows list", list_type) + continue + else: + log.info("Adding %d shows from Trakt's %s list", limit, list_type) + + # run shows + added_shows = shows.callback(list_type=list_type, add_limit=limit, + add_delay=add_delay, no_search=no_search, + notifications=notifications) + elif list_type.lower() == 'watchlist': + for authenticate_user, limit in value.items(): + if limit <= 0: + log.info("Skipped Trakt's %s for %s", list_type, authenticate_user) + continue + else: + log.info("Adding %d shows from the %s from %s", limit, list_type, authenticate_user) + + # run shows + added_shows = shows.callback(list_type=list_type, add_limit=limit, + add_delay=add_delay, no_search=no_search, + notifications=notifications, authenticate_user=authenticate_user) + elif list_type.lower() == 'lists': + for list, v in value.items(): + if isinstance(v, dict): + authenticate_user = v['authenticate_user'] + limit = v['limit'] + else: + authenticate_user = None + limit = v + + # run shows + added_shows = shows.callback(list_type=list, add_limit=limit, + add_delay=add_delay, no_search=no_search, + notifications=notifications, authenticate_user=authenticate_user) + if added_shows is None: log.error("Failed adding shows from Trakt's %s list", list_type) time.sleep(10) @@ -449,23 +631,57 @@ 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(): + added_movies = None + if list_type.lower() == 'interval': continue - elif 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) + if list_type.lower() in Trakt.non_user_lists: + limit = value + + if limit <= 0: + log.info("Skipped Trakt's %s movies list", list_type) + continue + else: + log.info("Adding %d movies from Trakt's %s list", limit, list_type) + + # run movies + added_movies = movies.callback(list_type=list_type, add_limit=limit, + add_delay=add_delay, no_search=no_search, + notifications=notifications) + elif list_type.lower() == 'watchlist': + for authenticate_user, limit in value.items(): + if limit <= 0: + log.info("Skipped Trakt's %s for %s", list_type, authenticate_user) + continue + else: + log.info("Adding %d movies from the %s from %s", limit, list_type, authenticate_user) + + # run movies + added_movies = movies.callback(list_type=list_type, add_limit=limit, + add_delay=add_delay, no_search=no_search, + notifications=notifications, authenticate_user=authenticate_user) + elif list_type.lower() == 'lists': + for list, v in value.items(): + if isinstance(v, dict): + authenticate_user = v['authenticate_user'] + limit = v['limit'] + else: + authenticate_user = None + limit = v + + # run shows + added_movies = movies.callback(list_type=list, add_limit=limit, + add_delay=add_delay, no_search=no_search, + notifications=notifications, authenticate_user=authenticate_user) + if added_movies is None: log.error("Failed adding movies from Trakt's %s list", list_type) time.sleep(10) @@ -517,7 +733,7 @@ def run(add_delay=2.5, no_search=False, no_notifications=False): try: # Sleep until next run log.info("Next job at %s", schedule.next_run()) - time.sleep(schedule.idle_seconds() or 0) + time.sleep(max(schedule.idle_seconds(), 0)) # Check jobs to run schedule.run_pending() @@ -547,4 +763,21 @@ def init_notifications(): ############################################################ if __name__ == "__main__": + print(""" + + ,--. ,--. ,--. +,-' '-.,--.--. ,--,--.| |,-.,-' '-. ,--,--.,--.--.,--.--. +'-. .-'| .--'' ,-. || /'-. .-'' ,-. || .--'| .--' + | | | | \ '-' || \ \ | | \ '-' || | | | + `--' `--' `--`--'`--'`--' `--' `--`--'`--' `--' + +######################################################################### +# Author: l3uddz # +# URL: https://github.com/l3uddz/traktarr # +# -- # +# Part of the Cloudbox project: https://cloudbox.rocks # +######################################################################### +# GNU General Public License v3.0 # +######################################################################### + """) app()