├── LICENSE ├── MANIFEST.in ├── README.md ├── example-retraktarr.conf ├── requirements.txt ├── retraktarr.py ├── retraktarr ├── VERSION ├── __init__.py ├── api │ ├── __init__.py │ ├── arr.py │ └── trakt.py ├── config.py └── retraktarr.py └── setup.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 zakary 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include retraktarr/VERSION 2 | include README.md 3 | include LICENSE 4 | include *.py 5 | include *.txt 6 | include MANIFEST.in 7 | exclude *.conf 8 | exclude .git* 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) 4 | [![GitHub issues](https://img.shields.io/github/issues/zakkarry/retraktarr.svg)](https://github.com/zakkarry/retraktarr/issues) 5 | [![GitHub pull requests](https://img.shields.io/github/issues-pr/zakkarry/retraktarr.svg)](https://github.com/zakkarry/retraktarr/pulls) 6 | [![GitHub stars](https://img.shields.io/github/stars/zakkarry/retraktarr.svg)](https://github.com/zakkarry/retraktarr/stargazers) 7 | [![Pypi.org Repo (pip)](https://img.shields.io/pypi/v/retraktarr)](https://pypi.org/project/retraktarr/) 8 | [![Python](https://img.shields.io/badge/python-3.11-blue.svg)](https://www.python.org/downloads/) 9 | [![Support](https://img.shields.io/badge/buy%20me-coffee-brown)](https://tip.ary.dev) 10 | 11 |
12 | 13 | # retraktarr 14 | 15 | `retraktarr` is a "reverse" [Trakt.tv](https://www.trakt.tv) list implementation for [Radarr](https://radarr.video)/[Sonarr](https://sonarr.tv) that creates [Trakt.tv](https://www.trakt.tv) lists for your movies/series using APIs. 16 | 17 | ## Introduction 18 | 19 | `retraktarr` is a Python script to sync your [Radarr](https://radarr.video)/[Sonarr](https://sonarr.tv) library to a [Trakt.tv](https://www.trakt.tv) list using the respective APIs. 20 | 21 | The original idea stemmed from my wanting to have a list of monitored movies I could share with friends. This was to be the equivalent of a mdblist, but cherry-picked. Providing a more curated list of what **I** believed was worth considering to watch for downloading. 22 | 23 | ## Uses 24 | 25 | The goal was to add the list to [Radarr](https://radarr.video), set up a filter for the list with Exists in Library and On Exclusion List = false, and allow friends to easily keep up to date with my recommended movies through the Discover tab. 26 | 27 | This use case was admittedly very narrow, and a few more use cases have emerged since. 28 | 29 | - Backing up entire Radarr/Sonarr libraries, including movies/shows you do not already have downloaded. For example, if it's missing in Radarr, PlexTraktSync would not help. 30 | - Restoring your library easily by importing an entire (backed up) list (in the case of migrating OSs or catastrophic failures) 31 | - Giving someone the ability to browse media you have (without giving them access to Plex/Jellyfin/Emby) 32 | - Sync multiple instances of Sonarr/Radarr 33 | - _Possibly more I have not considered..._ 34 | 35 | ## Requirements 36 | 37 | - [Python 3](https://www.python.org/downloads/) (including `requests` module if running from source) 38 | - `pip3 install requests` 39 | - [Radarr](https://radarr.video) and/or [Sonarr](https://sonarr.tv) 40 | - A [Trakt.tv](https://www.trakt.tv) account with [API App configured](#trakttv-api-app-setup) 41 | 42 | ## Arr Support 43 | 44 | `retraktarr` supports both [Radarr](https://radarr.video) and [Sonarr](https://sonarr.tv) in sourcing the media to sync to your lists. You can specify either or both for syncing, as well as filter what should be added with CLI arguments. 45 | 46 | `retraktarr` will need API access to whichever Arr(s) you intend to use. 47 | 48 | ## [Trakt.tv](https://www.trakt.tv) API App Setup 49 | 50 | A [Trakt.tv](https://www.trakt.tv) account with an [API set up](#trakttv-api-app-setup) is obviously necessary. 51 | 52 | - 🚨Note: [Trakt.tv](https://www.trakt.tv) lists have limits. You can read their official statement [here](https://twitter.com/trakt/status/1536751362943332352/photo/1)🚨 53 | 54 | 1. Head to [Trakt.tv API App Setup Page](https://trakt.tv/oauth/applications) 55 | 2. Create a new application, you will only **need** to fill `Name` and `RedirectURI` 56 | - I suggest using `https://google.com` for your redirect URI. We will need to steal a parameter from the redirect to complete the OAuth2 process. 57 | 3. After creating the application, click on it and you will see your `Client ID`, `Client Secret`, and an `Authorize` button. 58 | 4. Click `Authorize`. Click `Yes`. You will be redirected to Google (or your URI) and in the URL bar you will see `?code=` followed by 64 alphanumeric characters. **Save this for now. This is your OAuth2 Authorization code.** 59 | 5. You can now complete the OAuth2 process when you're ready using the `retraktarr`. 60 | 61 | ## Installing retraktarr 62 | 63 | You can either download the source yourself or install the package from PyPI using the `pip3 install retraktarr` command. 64 | 65 | ## Configuring retraktarr 66 | 67 | `retraktarr` uses a config file, named `retraktarr.conf` (by default) to get many of its settings. However, some of these can be overridden with an argument you pass. You can run `retraktarr` at any time to see the available options. 68 | 69 | To generate the config template, simply run `retraktarr` without a `.conf` file present. It will tell you exactly where the default config file was generated and it's location. 70 | 71 | Open in your favorite text editor and complete the necessary details for your usage. 72 | 73 | If you've never run `retraktarr` before, you will need to leave your `oauth2_token` and `oauth2_refresh` options blank and use the `--oauth` argument to [complete the authorization](#trakttv-api-app-setup) process and automatically save your tokens. They will be automatically refreshed if a valid refresh token is available upon expiration. 74 | 75 | ## Usage (CLI) 76 | 77 | ```shell 78 | options: 79 | -h, --help show this help message and exit 80 | --oauth OAUTH, -o OAUTH 81 | Update OAuth2 Bearer Token. Accepts the auth code and requires valid Trakt config settings 82 | (ex: -o CODE_HERE) 83 | --radarr, -r Synchronize Radarr movies with Trakt.tv 84 | --sonarr, -s Synchronize Sonarr series with Trakt.tv 85 | --all, -all, -a Synchronize both Starr apps with Trakt.tv 86 | --mon, -m Synchronize only monitored content with Trakt.tv 87 | --missing Synchronize only missing Radarr content with Trakt.tv 88 | --qualityprofile QUALITYPROFILE, -qp QUALITYPROFILE 89 | The quality profile you wish to sync to Trakt.tv 90 | --tag TAG, -t TAG The arr tag you wish to sync to Trakt.tv 91 | --cat, -c Add to the Trakt.tv list without deletion (concatenate/append to list) 92 | --list LIST, -l LIST Specifies the Trakt.tv list name. (overrides config file settings) 93 | --wipe, -w Erases the associated list and performs a sync (requires -all or -r/s) 94 | --privacy PRIVACY, -p PRIVACY 95 | Specifies the Trakt.tv list privacy settings (private/friends/public - overrides config file 96 | settings) 97 | --genre GENRE, -g GENRE 98 | Specifies the genre(s) of content to add to your list (OR logic) 99 | --refresh Forces a refresh_token exchange (oauth) and sets the config to a new tokens. 100 | --timeout TIMEOUT Specifies the timeout in seconds to use for POST commands to Trakt.tv 101 | --version Displays version information 102 | --config CONFIG If a path is provided, retraktarr will use this config file, otherwise it outputs default config location. 103 | ``` 104 | 105 | ## Troubleshooting 106 | 107 | - If you are running from the source, you will need to run `retraktarr.py` in the root directory, and not in the retraktarr directory. 108 | - If you are having problems with old entries not being removed, feel free to use the -wipe command in addition, it will delete the entire **contents** of the list **without** deleting the list itself, and then resync. 109 | - If you want to sync multiple "filters" (tag, profile, etc) to one list, consider running multiple times with your filter arguments and the additional `--cat/-c` parameter. 110 | - Privacy can only be set when the list is first created, specifying privacy on an already created list will do nothing. 111 | - Unless a list is specified using `-list` - when you use `--all` or `-r -s` - each Arr will sync to the list specified in the config.conf file. 112 | - Using filtered syncs with `-all` is not generally recommended, consider chaining multiple runs. 113 | - Syncing an instance will only remove non-syncing media in its associated type. If you have a list with movies and TV added and run a Sonarr sync to it, it will only remove **SHOWS** that are not present in the sync. (excludes usage of `--cat/-c`) 114 | - If you repeatedly get the same movies reporting as deleted, but not actually deleting, this is almost certainly due to an outdated ID (usually TMDB) being associated with the movie on Trakt. Report it and give them the correct link. If after it's updated it does not fix it, create an issue with details. 115 | - If you're getting timeouts during runs, particularly during `--wipe` or large list processing, use the `--timeout ` command. Default is 30, increase it until your list is processed completely. 116 | -------------------------------------------------------------------------------- /example-retraktarr.conf: -------------------------------------------------------------------------------- 1 | [Trakt] 2 | client_id = 3 | client_secret = 4 | username = 5 | redirect_uri = 6 | oauth2_token = 7 | oauth2_refresh = 8 | 9 | [Radarr] 10 | url = 11 | api_key = 12 | trakt_list = 13 | trakt_list_privacy = 14 | 15 | [Sonarr] 16 | url = 17 | api_key = 18 | trakt_list = 19 | trakt_list_privacy = 20 | 21 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | -------------------------------------------------------------------------------- /retraktarr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from retraktarr import main 3 | 4 | main() 5 | -------------------------------------------------------------------------------- /retraktarr/VERSION: -------------------------------------------------------------------------------- 1 | 1.1.4 -------------------------------------------------------------------------------- /retraktarr/__init__.py: -------------------------------------------------------------------------------- 1 | from .retraktarr import main 2 | -------------------------------------------------------------------------------- /retraktarr/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .arr import ArrAPI 2 | from .trakt import TraktAPI 3 | -------------------------------------------------------------------------------- /retraktarr/api/arr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ handles the arr api calls and requests """ 3 | import sys 4 | from urllib.parse import urlparse 5 | 6 | import requests 7 | 8 | 9 | class ArrAPI: 10 | """arr api handler class""" 11 | 12 | def __init__(self): 13 | self.api_url = "" 14 | self.api_key = "" 15 | self.endpoint = { 16 | "Sonarr": ("series", "tvdb", "shows"), 17 | "Radarr": ("movie", "tmdb", "movies"), 18 | } 19 | 20 | # queries arr and gets the return from the end point passed to it 21 | def arr_get(self, arr, endpoint, timeout): 22 | """sends the get request to the arr endpoint""" 23 | try: 24 | parsed_url = urlparse(self.api_url) 25 | 26 | url_host = parsed_url._replace( 27 | netloc=parsed_url.netloc.split("@")[-1] 28 | ).netloc 29 | 30 | url_path = parsed_url.path if parsed_url.path is not None else "" 31 | 32 | request_url = f"{parsed_url.scheme}://{url_host}{url_path}" 33 | 34 | if (parsed_url.username is None) or (parsed_url.password is None): 35 | response = requests.get( 36 | f"{request_url}/api/v3/{endpoint}", 37 | params={"apikey": self.api_key}, 38 | timeout=timeout, 39 | ) 40 | else: 41 | response = requests.get( 42 | f"{request_url}/api/v3/{endpoint}", 43 | params={"apikey": self.api_key}, 44 | timeout=timeout, 45 | auth=(parsed_url.username, parsed_url.password), 46 | ) 47 | response.raise_for_status() 48 | return response 49 | except requests.exceptions.ConnectTimeout: 50 | print(f"{arr}: Connection Timed Out. Check your URL.") 51 | sys.exit(1) 52 | except requests.exceptions.ConnectionError as error: 53 | print(f"{arr}: Connection Error. Check Your URL or server.") 54 | error = str(error).split("] ")[1].split("'")[0] 55 | print(f"{arr}: {error}") 56 | sys.exit(1) 57 | except requests.exceptions.HTTPError as error: 58 | if "401" in str(error): 59 | print( 60 | f"{arr} Error: API key incorrect. " 61 | "Please double check your key and config file." 62 | ) 63 | sys.exit(1) 64 | print(f"{arr} Error:") 65 | print(f"{arr}: {error}") 66 | sys.exit(1) 67 | 68 | def get_id(self, arr, search_term, endpoint, term): 69 | """sends a request to get get necessary ids""" 70 | response = self.arr_get(arr, endpoint, 10) 71 | 72 | # creates a dict for the term: id 73 | id_dict = {item[term]: item["id"] for item in response.json()} 74 | 75 | # if it can't find an id for the term error and exit 76 | if id_dict.get(search_term) is None: 77 | print( 78 | f'{arr} Error: No matching {endpoint if (endpoint != "qualityprofile") else "quality profile"} found.' 79 | ) 80 | sys.exit(1) 81 | 82 | # return the id 83 | return id_dict.get(search_term) 84 | 85 | def get_list(self, args, arr): 86 | """sends the get request to the movies/series arr endpoint""" 87 | response = self.arr_get(arr, f"{self.endpoint[arr][0]}", 10) 88 | arr_data = {} 89 | 90 | # parsing out the tmdb/tvdb/imdb id's, 91 | # monitored status, quality profile id, title, and any tags 92 | for item in response.json(): 93 | arr_data[item[f"{self.endpoint[arr][1]}Id"]] = [ 94 | item.get("imdbId"), 95 | item.get("monitored"), 96 | item.get("qualityProfileId"), 97 | item.get("title"), 98 | item.get("tags"), 99 | item.get("hasFile") if (arr == "Radarr") else None, 100 | item.get("genres"), 101 | ] 102 | arr_ids = list(arr_data.keys()) 103 | 104 | # if its monitored, add to arr ids 105 | if args.mon: 106 | arr_ids = [key for key, value in arr_data.items() if value[1]] 107 | 108 | # get the current filtered arr_ids that qualify for the specified quality profile 109 | if args.qualityprofile: 110 | qp_id = self.get_id(arr, args.qualityprofile, "qualityprofile", "name") 111 | arr_ids = list( 112 | filter( 113 | lambda arr_data_item: arr_data.get(arr_data_item, [None])[2] 114 | is qp_id, 115 | arr_ids, 116 | ) 117 | ) 118 | 119 | # same as above, but for tags 120 | if args.tag: 121 | tag_id = self.get_id(arr, args.tag, "tag", "label") 122 | arr_ids = list( 123 | filter( 124 | lambda arr_tag_item: tag_id 125 | in arr_data.get(arr_tag_item, [None])[4], 126 | arr_ids, 127 | ) 128 | ) 129 | if arr == "Radarr" and args.missing: 130 | arr_ids = list( 131 | filter( 132 | lambda arr_data_item: arr_data.get(arr_data_item, [None])[5] 133 | == False, 134 | arr_ids, 135 | ) 136 | ) 137 | if args.genre: 138 | genres = [genre.strip() for genre in args.genre.split(",")] 139 | arr_ids = list( 140 | filter( 141 | lambda arr_genre_data_item: any( 142 | genre in arr_data.get(arr_genre_data_item, [None])[6] 143 | for genre in genres 144 | ), 145 | arr_ids, 146 | ) 147 | ) 148 | 149 | # if imdb id is in arr, add it to the imdb id list 150 | arr_imdb = [ 151 | value[1][0] 152 | for value in arr_data.items() 153 | if value[1][0] is not None and value[0] in arr_ids 154 | ] 155 | 156 | return arr_ids, arr_imdb, arr_data 157 | -------------------------------------------------------------------------------- /retraktarr/api/trakt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ handles the trakt api lists and requests (add/delete/etc) """ 3 | import json 4 | import re 5 | import sys 6 | import time 7 | 8 | import requests 9 | 10 | from retraktarr.config import Configuration 11 | 12 | 13 | class TraktAPI: 14 | """trakt API handler class""" 15 | 16 | def __init__(self, oauth2_bearer, trakt_api_key, trakt_user, trakt_secret): 17 | self.oauth2_bearer = oauth2_bearer 18 | self.trakt_api_key = trakt_api_key 19 | self.user = trakt_user 20 | self.trakt_secret = trakt_secret 21 | self.list = "" 22 | self.json = {} 23 | self.response = None 24 | self.list_len = [] 25 | self.list_privacy = "public" 26 | self.list_limit = 1000 27 | self.post_timeout = 60 28 | self.trakt_session = requests.Session() 29 | self.trakt_hdr = { 30 | "Content-Type": "application/json", 31 | "trakt-api-version": "2", 32 | "trakt-api-key": self.trakt_api_key, 33 | "Authorization": f"Bearer {self.oauth2_bearer}", 34 | } 35 | 36 | def refresh_header(self, oauth_token): 37 | """refresh header (used for oauth automatic refresh)""" 38 | self.trakt_hdr = { 39 | "Content-Type": "application/json", 40 | "trakt-api-version": "2", 41 | "trakt-api-key": self.trakt_api_key, 42 | "Authorization": f"Bearer {oauth_token}", 43 | } 44 | 45 | @staticmethod 46 | def normalize_trakt(to_normalize): 47 | # remove all parenthesis and brackets 48 | normalized = re.sub(r"[()\[\]]", "", to_normalize) 49 | # non-'-' and '_' characters with a hyphen 50 | normalized = re.sub(r"[^a-zA-Z0-9-_]", "-", normalized) 51 | # duplicate sequential hyphens 52 | normalized = re.sub(r"-+", "-", normalized) 53 | return normalized.strip("-") 54 | 55 | def get_trakt(self, path, args, media_type, timeout): 56 | """gets json response from the specified path for applicable media_type (show/movie)""" 57 | response = None 58 | time.sleep(1) 59 | try: 60 | response = self.trakt_session.get( 61 | f"https://api.trakt.tv/{path}", headers=self.trakt_hdr, timeout=timeout 62 | ) 63 | response.raise_for_status() 64 | if response.status_code != 200: 65 | print( 66 | f"Trakt.tv Error: Unexpected status code return: {response.status_code}." 67 | ) 68 | sys.exit(1) 69 | else: 70 | return response 71 | except requests.exceptions.ConnectTimeout: 72 | print("Trakt.tv Error: Connection Timed Out. Check your internet.") 73 | sys.exit(1) 74 | except requests.exceptions.ConnectionError as error: 75 | print("Trakt.tv: Connection Error. Check your internet.") 76 | print(f"{error}") 77 | sys.exit(1) 78 | except requests.exceptions.HTTPError as error: 79 | # checks if the list is missing mostly 80 | if "404" in str(error): 81 | return 404 82 | # checks if an oauth_refresh token is available 83 | # and if so assume that the token has expired and attempt a refresh automatically 84 | if "401" in str(error) or "400" in str(error) or "403" in str(error): 85 | config = Configuration("retraktarr.conf") 86 | 87 | # checks the config for the refresh, if exists, update the 88 | # header and rerun the command and return original intended results 89 | if ( 90 | config.conf.get("Trakt", "oauth2_refresh") 91 | and self.oauth2_bearer 92 | == self.trakt_hdr.get("Authorization").split(" ")[1] 93 | ): 94 | print( 95 | "Error: You may have a expired token. Attempting a refresh command." 96 | ) 97 | 98 | # refresh the header with the new auth, update the config file with new tokens 99 | self.refresh_header(config.get_oauth(args, True)) 100 | time.sleep(1) 101 | 102 | # return the intended original results 103 | return self.get_trakt(path, args, media_type, timeout=timeout) 104 | 105 | # no oauth_refresh token is available, error out. 106 | print( 107 | "Error: You likely have a bad ClientID/Secret or expired/invalid token." 108 | "\nPlease check your config and attempt the refresh or oauth command (-o) again" 109 | ) 110 | sys.exit(1) 111 | print( 112 | f"Trakt.tv Error: Unexpected status code return: {response.status_code}." 113 | ) 114 | sys.exit(1) 115 | 116 | def get_list(self, args, media_type): 117 | """ " gets the specified trakt list and settings (account limits)""" 118 | 119 | # grabs the users settings and sets the list limits 120 | response = self.get_trakt("users/settings", args, media_type, timeout=10) 121 | self.list_limit = ( 122 | response.json().get("limits", {}).get("list", {}).get("item_count", None) 123 | ) 124 | 125 | # sends a get request for the list and all of its items 126 | response = self.get_trakt( 127 | f"users/{self.normalize_trakt(self.user)}/lists/{self.normalize_trakt(self.list)}/items", 128 | args, 129 | media_type, 130 | timeout=30, 131 | ) 132 | 133 | # returns empty lists if the list does not exist 134 | if response == 404: 135 | return [], [], [], [] 136 | 137 | # sets all of the ids into lists for parsing/adding/logic 138 | self.list_len = [ 139 | item.get("id", {}) for item in response.json() if item.get("id", {}) 140 | ] 141 | 142 | tvdb_ids = [ 143 | item.get(media_type, {}).get("ids", {}).get("tvdb") 144 | for item in response.json() 145 | if item.get(media_type, {}).get("ids", {}).get("tvdb") is not None 146 | ] 147 | 148 | tmdb_ids = [ 149 | item.get(media_type, {}).get("ids", {}).get("tmdb") 150 | for item in response.json() 151 | if item.get(media_type, {}).get("ids", {}).get("tmdb") is not None 152 | ] 153 | 154 | imdb_ids = [ 155 | item.get(media_type, {}).get("ids", {}).get("imdb") 156 | for item in response.json() 157 | if item.get(media_type, {}).get("ids", {}).get("imdb") is not None 158 | ] 159 | 160 | # makes a list of all trakt ids so we have every single item 161 | # guarenteed (we use this id for wiping) 162 | trakt_ids = [ 163 | item.get("movie", {}).get("ids", {}).get("trakt") 164 | for item in response.json() 165 | if item.get("movie", {}).get("ids", {}).get("trakt") is not None 166 | ] 167 | for item in response.json(): 168 | if item.get("show", {}).get("ids", {}).get("trakt") is not None: 169 | trakt_ids.append(item.get("show", {}).get("ids", {}).get("trakt")) 170 | 171 | # spit out the lists and json to main 172 | self.json = response.json() 173 | return tvdb_ids, tmdb_ids, imdb_ids, trakt_ids 174 | 175 | def post_trakt(self, list_name, path, post_json, args, media_type, timeout): 176 | """ 177 | sends a post command to trakt 178 | post_json is json.dumps'd json, path is the url to append to the user url 179 | """ 180 | 181 | time.sleep(1) 182 | try: 183 | response = self.trakt_session.post( 184 | f"https://api.trakt.tv/users/{self.normalize_trakt(self.user)}/{path}", 185 | headers=self.trakt_hdr, 186 | data=post_json, 187 | timeout=timeout if not args.timeout else self.post_timeout, 188 | ) 189 | response.raise_for_status() 190 | if response.status_code in (200, 201, 204): 191 | return response 192 | except requests.exceptions.ConnectTimeout: 193 | print("Trakt.tv Error: Connection Timed Out. Check your internet.") 194 | sys.exit(1) 195 | except requests.exceptions.ReadTimeout: 196 | print( 197 | "Trakt.tv Error: Connection Timed Out Mid-Stream. Increase your --timeout. " 198 | ) 199 | sys.exit(1) 200 | except requests.exceptions.ConnectionError as error: 201 | print("Trakt.tv: Connection Error. Check your internet.") 202 | print(f"{error}") 203 | sys.exit(1) 204 | except requests.exceptions.HTTPError as error: 205 | # http error parsing 206 | if "401" in str(error) or "403" in str(error): 207 | print( 208 | "Trakt.tv Error: You likely have a bad OAuth2 Token, " 209 | "username, or ClientID/API key.\n" 210 | "Please check your config, revalidate with the oauth2 " 211 | "command, and try again." 212 | ) 213 | sys.exit(1) 214 | elif "420" in str(error): 215 | print( 216 | "Trakt.tv Error:" 217 | f"Your additions to ({list_name}) exceeds your item limits." 218 | "You will need Trakt VIP." 219 | ) 220 | sys.exit(1) 221 | elif "404" in str(error): 222 | # if the list doesn't exist, we create it 223 | # then rerun the same post commands 224 | # return the response as if nothing happened :) 225 | print( 226 | "Trakt.tv Error (404): " 227 | f"https://trakt.tv/users/{self.normalize_trakt(self.user)}/lists/{self.normalize_trakt(self.list)} not found...\n" 228 | ) 229 | trakt_add_list = { 230 | "name": self.list, 231 | "description": "Created using retraktarr " 232 | "(https://github.com/zakkarry/retraktarr)", 233 | "privacy": self.list_privacy, 234 | "allow_comments": False, 235 | } 236 | # adds the list 237 | self.post_trakt( 238 | self.list, 239 | "lists", 240 | json.dumps(trakt_add_list), 241 | args, 242 | media_type, 243 | timeout=15, 244 | ) 245 | print(f"Creating {self.list_privacy} Trakt.tv list: ({self.list})...\n") 246 | time.sleep(1) 247 | 248 | # retry the POST and returns the intended original results 249 | response = self.post_trakt( 250 | self.list, 251 | path, 252 | post_json, 253 | args, 254 | media_type, 255 | timeout=timeout if not args.timeout else self.post_timeout, 256 | ) 257 | return response 258 | 259 | def del_from_list( 260 | self, 261 | args, 262 | media_type, 263 | arr_data, 264 | trakt_ids, 265 | idtag, 266 | trakt_imdb_ids, 267 | arr_ids, 268 | arr_imdb, 269 | all_trakt_ids, 270 | ): 271 | """ 272 | super complicated logic to find and identify unneeded ids 273 | and remove them from the trakt list before adding 274 | """ 275 | trakt_del, extra_imdb_ids, extra_ids, filtered_extra_imdb_ids, wrong_ids = ( 276 | [] for i in range(5) 277 | ) 278 | if not args.cat: # not catenating to the list 279 | if len(all_trakt_ids) > 0: # if the trakt list already has contents 280 | # if we're not wiping the entire list (--wipe) 281 | if not args.wipe: 282 | needed_ids = set(arr_ids) - set(trakt_ids) 283 | # set needed ids to all wanted ids from arr minus whats already on the list 284 | 285 | extra_ids = set(trakt_ids) - set(arr_ids) 286 | # all items from the list minus what is in arrs - tmdb/tvdb to be removed 287 | 288 | extra_imdb_ids = set(trakt_imdb_ids) - set(arr_imdb) 289 | # same as above, but specifically imdbids in case tmdb/tvdb is missing 290 | 291 | # run through trakt's API json response for the list 292 | # check the type of media and compare to what arr's media is 293 | # see if the idtag (tvdb/tmdb) is missing 294 | # and add the imdb id from trakt to a filtered extra's list 295 | filtered_extra_imdb_ids = [ 296 | data[media_type.rstrip("s")]["ids"].get("imdb") 297 | for data in filter( 298 | lambda data: data["type"] == media_type.rstrip("s") 299 | and data[media_type.rstrip("s")]["ids"].get(idtag) is None 300 | and data[media_type.rstrip("s")]["ids"].get("imdb") 301 | in extra_imdb_ids, 302 | self.json, 303 | ) 304 | ] 305 | 306 | # check if there are extra tmdb/tvdb id's to be removed from trakt 307 | # skip if wipe since we'd remove all 308 | if len(extra_ids) > 0 and not args.wipe: 309 | trakt_del = {media_type: []} 310 | # create a dictionary for 'imdb' values in arr_data that could be wrong 311 | arr_imdb_lookup = { 312 | value[1][0]: value[0] 313 | for value in arr_data.items() 314 | if value[0] in needed_ids 315 | } 316 | # run through extra_ids 317 | # check if the extra_id from trakt doesnt exist in arrs 318 | # (almost always deleted/outdate trakt info) 319 | for item in extra_ids: 320 | if item not in arr_ids: 321 | # if it isn't in our arr, append to the delete json 322 | # we will readd if its on the needed_ids list later. 323 | trakt_del[media_type].append({"ids": {idtag: item}}) 324 | 325 | # checks if the tmdb/tvdb is not in the arr's db 326 | # ends up determining if the id is wrong and it will be readded 327 | if item not in arr_data.keys(): 328 | # run through the json 329 | for data in self.json: 330 | # check if the tvdb/tmdb id is in the arr 331 | if ( 332 | data[media_type.rstrip("s")]["ids"].get( 333 | idtag 334 | ) 335 | == item 336 | ) and ( 337 | data[media_type.rstrip("s")]["ids"].get( 338 | "imdb" 339 | ) 340 | in arr_imdb_lookup 341 | ): 342 | # if the imdb id is correct but tmdb/tvdb is not, add to wrong_ids 343 | wrong_ids.append(item) 344 | break 345 | # remove the needed wrong ids from extra_ids so they dont delete 346 | extra_ids = set(extra_ids) - set(wrong_ids) 347 | 348 | # takes the filtered extra imdb ids 349 | # (missing a corresponding tmdb/tvdb from arr on trakt) 350 | if len(filtered_extra_imdb_ids) > 0 and not args.wipe: 351 | # if there's no extra_ids (tvdb/tmdb) 352 | # just build the entire json for imdb since it would be empty anyway 353 | if len(extra_ids) == 0: 354 | trakt_del = { 355 | media_type: [ 356 | {"ids": {"imdb": item}} 357 | for item in filtered_extra_imdb_ids 358 | ] 359 | } 360 | # else, run through the filtered id's 361 | # append the imdbid's without corresponding tvdb/tmdb in arr for removal 362 | else: 363 | for item in filtered_extra_imdb_ids: 364 | trakt_del[media_type].append({"ids": {"imdb": item}}) 365 | 366 | # if filtered is less is 0 or wipe, set needed_ids to all 367 | # build a json to delete everything (in case of wipe) 368 | # we wont run delete if the list is empty anyway... 369 | else: 370 | needed_ids = set(arr_ids) 371 | trakt_del = { 372 | "shows": [{"ids": {"trakt": item}} for item in all_trakt_ids], 373 | "movies": [{"ids": {"trakt": item}} for item in all_trakt_ids], 374 | } 375 | else: 376 | needed_ids = set(arr_ids) 377 | else: 378 | needed_ids = set(arr_ids) 379 | # does some calculations on what the end list count would be 380 | # compares to your trakt list limits 381 | if ( 382 | ( 383 | len(self.list_len) 384 | + len(needed_ids) 385 | - len(extra_ids) 386 | - len(filtered_extra_imdb_ids) 387 | ) 388 | > self.list_limit 389 | ) or (args.wipe and (len(needed_ids) > self.list_limit)): 390 | print( 391 | f"Error: Your additions to ({self.list}) exceeds your item limits." 392 | "You will need Trakt VIP." 393 | ) 394 | sys.exit(1) 395 | 396 | # checks if there are extra ids to be removed 397 | # or list has items and a wipe was requested 398 | # since we removed wrong id's this wont be ran if there is nothing but wrong ids.... 399 | if (len(extra_ids) > 0 or len(filtered_extra_imdb_ids) > 0) or ( 400 | args.wipe and (len(all_trakt_ids) > 0) 401 | ): 402 | # sends the remove from list request 403 | self.post_trakt( 404 | self.list, 405 | f"lists/{self.normalize_trakt(self.list)}/items/remove", 406 | json.dumps(trakt_del), 407 | args, 408 | media_type, 409 | timeout=60, 410 | ) 411 | 412 | # if not a wipe, display what was deleted...dont flood if wiping :P 413 | if not args.wipe: 414 | print( 415 | f"Number of Deleted {media_type.title()}: " 416 | f"{len(extra_ids) + len(filtered_extra_imdb_ids)}" 417 | ) 418 | 419 | # iterate through extra ids (tvdb/tmdb) 420 | # display the idname, the title, and the id itself 421 | for extra_id in extra_ids: 422 | # uses the arr's json structure to display info 423 | if extra_id in arr_data: 424 | print( 425 | f" {idtag.upper()}: " 426 | f"{arr_data.get(extra_id, [None])[3]} - {extra_id}" 427 | ) 428 | continue 429 | 430 | # if it can't grab the data from arr, it was deleted from the arr 431 | # it will search trakt's json (slower) for the titles 432 | for data in self.json: 433 | if data[media_type.rstrip("s")]["ids"].get(idtag) == extra_id: 434 | print( 435 | f" {idtag.upper()}: " 436 | f"{data[media_type.rstrip('s')].get('title')} - {extra_id}" 437 | ) 438 | break 439 | 440 | # same as above, but for imdb, but missing tvdb/tmdb on trakt 441 | # loops through filtered extra imdb's copy, removing as it finds matches, 442 | # so it doesnt double display since the data might be in both arr and trakt 443 | arr_data_items = arr_data.items() 444 | for item in filtered_extra_imdb_ids.copy(): 445 | for value in arr_data_items: 446 | if item == value[1][0]: 447 | print(f" IMDB: {value[1][3]} - {value[1][0]}") 448 | filtered_extra_imdb_ids.remove(item) 449 | break 450 | if item in filtered_extra_imdb_ids: 451 | for data in self.json: 452 | imdbid = data[media_type.rstrip("s")]["ids"].get("imdb") 453 | if imdbid == item: 454 | print( 455 | f" IMDB: {data[media_type.rstrip('s')].get('title')}" 456 | f" - {imdbid}" 457 | ) 458 | break 459 | return needed_ids 460 | 461 | def add_to_list( 462 | self, 463 | args, 464 | media_type, 465 | arr_data, 466 | trakt_ids, 467 | idtag, 468 | trakt_imdb_ids, 469 | arr_ids, 470 | arr_imdb, 471 | all_trakt_ids, 472 | ): 473 | """ 474 | parse out and compares arr lists with trakt, then runs 475 | del_from_list and updates/adds to the list 476 | """ 477 | 478 | # blank type for trakt_add - trakt_add = {media_type: []} 479 | needed_ids = self.del_from_list( 480 | args, 481 | media_type, 482 | arr_data, 483 | trakt_ids, 484 | idtag, 485 | trakt_imdb_ids, 486 | arr_ids, 487 | arr_imdb, 488 | all_trakt_ids, 489 | ) 490 | 491 | # build the add to list json, if imdb is not available just use tmdb/tvdb 492 | trakt_add = { 493 | media_type: [ 494 | ( 495 | {"ids": {idtag: item, "imdb": arr_data.get(item, [None])[0]}} 496 | if arr_data.get(item, [None])[0] is not None 497 | else {"ids": {idtag: item}} 498 | ) 499 | for item in needed_ids 500 | ] 501 | } 502 | # sends the add to list request 503 | response = self.post_trakt( 504 | self.list, 505 | f"lists/{self.normalize_trakt(self.list)}/items", 506 | json.dumps(trakt_add), 507 | args, 508 | media_type, 509 | timeout=60, 510 | ) 511 | 512 | # gets the count for the add results... 513 | added_items = response.json()["added"][media_type.lower()] 514 | listed_items = response.json()["list"]["item_count"] 515 | not_found_items = response.json()["not_found"].get(media_type.lower(), []) 516 | 517 | # sets the state for items that parsing what really wasnt added due to not corresponding ids 518 | real_not_found_items = [] 519 | 520 | # goes through the ids of not found items on the add request 521 | for not_found_item in not_found_items: 522 | # grabs the tvdb/tmdb id of the not found item 523 | # we can assume it exists since its coming from arr 524 | idtag_value = not_found_item.get("ids", {}).get(idtag) 525 | 526 | # make sure its not a fucked up json response, and check that the response exists. 527 | # then append the real not found 528 | if idtag_value is not None and idtag_value in arr_data.keys(): 529 | real_not_found_items.append(idtag_value) 530 | 531 | print(f"Number of {media_type.title()} Added: {added_items}") 532 | 533 | # if real not found is over 0, print the results from the arr_data 534 | if len(real_not_found_items) > 0: 535 | print( 536 | f"Number of {media_type.title()} Not Found: {len(real_not_found_items)}" 537 | ) 538 | for real_not_found_item in real_not_found_items: 539 | print( 540 | f" {idtag.upper()}: " 541 | f"{arr_data.get(real_not_found_item, [None])[3]} - {real_not_found_item}" 542 | ) 543 | 544 | # if real not found is 0, finish up 545 | else: 546 | print(f"Number of {media_type.title()} Not Found: 0") 547 | print(f"Number of {media_type.title()} Listed: {listed_items}") 548 | 549 | # close the requests session 550 | self.trakt_session.close() 551 | -------------------------------------------------------------------------------- /retraktarr/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ validation and config generation - pretty standard shit """ 3 | import configparser 4 | import os 5 | import re 6 | import sys 7 | 8 | import requests 9 | 10 | 11 | class Configuration: 12 | """configuration file class""" 13 | 14 | def __init__(self, config_file): 15 | self.conf = configparser.ConfigParser() 16 | self.config_file = config_file 17 | if not os.path.exists(config_file): 18 | print( 19 | f"Error: Configuration file '{config_file}' not found. Creating blank config." 20 | ) 21 | try: 22 | self.conf["Trakt"] = { 23 | "client_id": "", 24 | "client_secret": "", 25 | "username": "", 26 | "redirect_uri": "", 27 | "oauth2_token": "", 28 | "oauth2_refresh": "", 29 | } 30 | self.conf["Radarr"] = { 31 | "url": "", 32 | "api_key": "", 33 | "trakt_list": "", 34 | "trakt_list_privacy": "", 35 | } 36 | self.conf["Sonarr"] = { 37 | "url": "", 38 | "api_key": "", 39 | "trakt_list": "", 40 | "trakt_list_privacy": "", 41 | } 42 | with open(config_file, "w", encoding="utf-8") as configfile: 43 | self.conf.write(configfile) 44 | print( 45 | "Please configure for oauth, use the -o=CODE parameter " 46 | "and valid config credentials." 47 | ) 48 | except configparser.Error as error: 49 | print("An error occurred:", error) 50 | sys.exit(1) 51 | else: 52 | try: 53 | self.conf.read(config_file) 54 | except configparser.Error as error: 55 | print(f"Error occurred while reading the configuration file: {error}") 56 | sys.exit(1) 57 | 58 | def get_oauth(self, args, refresh=False): 59 | """gets the oauth token via refresh or code""" 60 | authorization_code = None 61 | try: 62 | client_id = self.conf.get("Trakt", "client_id") 63 | client_secret = self.conf.get("Trakt", "client_secret") 64 | redirect_uri = self.conf.get("Trakt", "redirect_uri") 65 | if args.oauth: 66 | authorization_code = args.oauth 67 | if refresh or args.refresh: 68 | authorization_code = self.conf.get("Trakt", "oauth2_refresh") 69 | if authorization_code is None: 70 | print( 71 | "Trakt.tv Error: Exchanging refresh token failed. " 72 | "You do not have a valid refresh_token in your config. " 73 | "Please use -o instead." 74 | ) 75 | sys.exit(1) 76 | if ( 77 | (len(authorization_code) != 64) 78 | or (len(client_id) != 64) 79 | or (len(client_secret) != 64) 80 | ): 81 | print( 82 | "You need to set and provide a valid code, client_id, and client_secret. " 83 | "Please double check, and rerun the oauth command." 84 | ) 85 | sys.exit(1) 86 | if not re.match( 87 | r"^(?:https?://)?(?:[-\w.]+)+(?::\d+)?(?:/.*)?$", redirect_uri 88 | ): 89 | print( 90 | "You need to set the redirect_uri value to match trakt " 91 | "and rerun the oauth command." 92 | ) 93 | sys.exit(1) 94 | except configparser.Error as error: 95 | print(f"Error occurred while reading the configuration values: {error}") 96 | sys.exit(1) 97 | 98 | oauth_request = { 99 | "code": authorization_code, 100 | "client_id": client_id, 101 | "client_secret": client_secret, 102 | "redirect_uri": redirect_uri, 103 | "grant_type": "authorization_code", 104 | } 105 | if args.refresh or refresh: 106 | oauth_request["grant_type"] = "refresh_token" 107 | oauth_request["refresh_token"] = authorization_code 108 | try: 109 | response = requests.post( 110 | "https://api.trakt.tv/oauth/token", 111 | json=oauth_request, 112 | headers={"Content-Type": "application/json"}, 113 | timeout=10, 114 | ) 115 | response.raise_for_status() 116 | print("Authorization Code: ", authorization_code) 117 | print("Access Token: ", response.json().get("access_token")) 118 | print("Refresh Token: ", response.json().get("refresh_token")) 119 | self.conf.set("Trakt", "oauth2_token", response.json().get("access_token")) 120 | self.conf.set( 121 | "Trakt", "oauth2_refresh", response.json().get("refresh_token") 122 | ) 123 | with open(self.config_file, "w", encoding="utf-8") as configfile: 124 | self.conf.write(configfile) 125 | print( 126 | "Your configuration file was successfully updated " 127 | "with your access/refresh token.\n" 128 | ) 129 | if args.refresh is True and refresh is False: 130 | sys.exit(1) 131 | else: 132 | return response.json().get("access_token") 133 | except requests.exceptions.RequestException as error: 134 | print(error) 135 | print("Check your configuration, make sure they match Trakt.tv exactly") 136 | print( 137 | "Further Information: https://trakt.docs.apiary.io/#introduction/status-codes" 138 | ) 139 | sys.exit(1) 140 | 141 | def validate_trakt_credentials(self): 142 | """validates trakt.tv credentials""" 143 | try: 144 | oauth2_bearer = self.conf.get("Trakt", "oauth2_token") 145 | trakt_api_key = self.conf.get("Trakt", "client_id") 146 | trakt_secret = self.conf.get("Trakt", "client_secret") 147 | user = self.conf.get("Trakt", "username") 148 | except configparser.Error as error: 149 | print(f"Error occurred while reading the configuration values: {error}") 150 | sys.exit(1) 151 | if len(user) == 0: 152 | print( 153 | "Error: Invalid configuration values. " 154 | "[Trakt] username should not be empty." 155 | ) 156 | sys.exit(1) 157 | # validate the lengths of all the keys are correct 158 | if ( 159 | len(oauth2_bearer) != 64 160 | or len(trakt_api_key) != 64 161 | or len(trakt_secret) != 64 162 | ): 163 | print( 164 | "Error: Invalid configuration values. " 165 | "[Trakt] oauth2_token/client_id/client_secret " 166 | "should all have lengths of 64 characters." 167 | ) 168 | print( 169 | "Run with -o parameter with accurate username, redirect_uri, " 170 | "client_id, and client_secret values set in config." 171 | ) 172 | sys.exit(1) 173 | return oauth2_bearer, trakt_api_key, user, trakt_secret 174 | 175 | def validate_arr_configuration(self, arr_api, trakt_api, arr, args): 176 | """validates the specified arr config""" 177 | try: 178 | if not args.list: 179 | trakt_api.list = None 180 | if not args.privacy: 181 | trakt_api.list_privacy = None 182 | 183 | arr_api.api_url = self.conf.get(arr, "url").rstrip("/") 184 | arr_api.api_key = self.conf.get(arr, "api_key") 185 | trakt_api.list_privacy = ( 186 | self.conf.get(arr, "trakt_list_privacy") 187 | if (trakt_api.list_privacy is None) 188 | else trakt_api.list_privacy 189 | ) 190 | trakt_api.list = ( 191 | self.conf.get(arr, "trakt_list") 192 | if (trakt_api.list is None) 193 | else trakt_api.list 194 | ) 195 | except configparser.Error as error: 196 | print(f"Error occurred while reading the configuration values: {error}") 197 | sys.exit(1) 198 | if not re.match( 199 | r"^(?:https?://)?(?:.+:.+@)?(?:[-\w.]+)+(?::\d+)?(?:/.*)?$", arr_api.api_url 200 | ): 201 | print( 202 | f"Error: Invalid configuration value. [{arr}] 'url' does not match a URL pattern." 203 | ) 204 | sys.exit(1) 205 | if len(arr_api.api_key) != 32: 206 | print( 207 | f"Error: Invalid configuration values. " 208 | f"[{arr}] api_key should have lengths of 32 characters." 209 | ) 210 | sys.exit(1) 211 | -------------------------------------------------------------------------------- /retraktarr/retraktarr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ main script, arguments and executions """ 3 | import argparse 4 | import sys 5 | from os import path 6 | 7 | from retraktarr.api.arr import ArrAPI 8 | from retraktarr.api.trakt import TraktAPI 9 | from retraktarr.config import Configuration 10 | 11 | 12 | def main(): 13 | """main entry point defines args and processes stuff""" 14 | try: 15 | with open( 16 | path.join(path.dirname(path.abspath(__file__)), "VERSION"), encoding="utf-8" 17 | ) as f: 18 | VERSION = f.read() 19 | except OSError: 20 | VERSION = "MISSING" 21 | 22 | parser = argparse.ArgumentParser( 23 | description="Starr App -> Trakt.tv List Backup/Synchronization" 24 | ) 25 | parser.add_argument( 26 | "--oauth", 27 | "-o", 28 | type=str, 29 | help="Update OAuth2 Bearer Token." 30 | " Accepts the auth code and requires valid Trakt " 31 | "config settings (ex: -o CODE_HERE)", 32 | ) 33 | parser.add_argument( 34 | "--radarr", 35 | "-r", 36 | action="store_true", 37 | help="Synchronize Radarr movies with Trakt.tv", 38 | ) 39 | parser.add_argument( 40 | "--sonarr", 41 | "-s", 42 | action="store_true", 43 | help="Synchronize Sonarr series with Trakt.tv", 44 | ) 45 | parser.add_argument( 46 | "--all", 47 | "-all", 48 | "-a", 49 | action="store_true", 50 | help="Synchronize both Starr apps with Trakt.tv", 51 | ) 52 | parser.add_argument( 53 | "--mon", 54 | "-m", 55 | action="store_true", 56 | help="Synchronize only monitored content with Trakt.tv", 57 | ) 58 | parser.add_argument( 59 | "--missing", 60 | action="store_true", 61 | help="Synchronize only missing Radarr content with Trakt.tv", 62 | ) 63 | parser.add_argument( 64 | "--qualityprofile", 65 | "-qp", 66 | type=str, 67 | help="The quality profile you wish to sync to Trakt.tv", 68 | ) 69 | parser.add_argument( 70 | "--tag", "-t", type=str, help="The arr tag you wish to sync to Trakt.tv" 71 | ) 72 | parser.add_argument( 73 | "--cat", 74 | "-c", 75 | action="store_true", 76 | help="Add to the Trakt.tv list without " 77 | "deletion (concatenate/append to list)", 78 | ) 79 | parser.add_argument( 80 | "--list", 81 | "-l", 82 | type=str, 83 | help="Specifies the Trakt.tv list name. (overrides config file settings)", 84 | ) 85 | parser.add_argument( 86 | "--wipe", 87 | "-w", 88 | action="store_true", 89 | help="Erases the associated list and performs a sync " 90 | "(requires -all or -r/s)", 91 | ) 92 | parser.add_argument( 93 | "--privacy", 94 | "-p", 95 | type=str, 96 | help="Specifies the Trakt.tv list privacy settings " 97 | "(private/friends/public - overrides config file settings)", 98 | ) 99 | parser.add_argument( 100 | "--genre", 101 | "-g", 102 | type=str, 103 | help="Specifies the genre(s) of content to add to your list (OR logic)", 104 | ) 105 | parser.add_argument( 106 | "--refresh", 107 | action="store_true", 108 | help="Forces a refresh_token exchange (oauth) " 109 | "and sets the config to a new tokens.", 110 | ) 111 | parser.add_argument( 112 | "--timeout", 113 | type=str, 114 | help="Specifies the timeout in seconds to use for " "POST commands to Trakt.tv", 115 | ) 116 | parser.add_argument( 117 | "--version", 118 | action="store_true", 119 | help="Displays current version information", 120 | ) 121 | parser.add_argument( 122 | "--config", 123 | action="store", 124 | nargs="?", 125 | const=True, 126 | default=None, 127 | help="If a path is provided, retraktarr will use this config file, otherwise it outputs default config location.", 128 | ) 129 | args = parser.parse_args() 130 | print(f"\nretraktarr v{VERSION}") 131 | if args.version: 132 | sys.exit(0) 133 | 134 | if args.config is not True and args.config is not None: 135 | config_path = args.config 136 | else: 137 | config_path = ( 138 | f'{path.expanduser("~")}{path.sep}.config{path.sep}retraktarr.conf' 139 | ) 140 | if args.config is True: 141 | print(f"Current default config file is {config_path}") 142 | exit(0) 143 | 144 | print(f"Validating Configuration File: {config_path}\n") 145 | config = Configuration(config_path) 146 | if args.oauth: 147 | config.get_oauth(args) 148 | if args.refresh: 149 | config.get_oauth(args) 150 | 151 | ( 152 | oauth2_bearer, 153 | trakt_api_key, 154 | trakt_user, 155 | trakt_secret, 156 | ) = config.validate_trakt_credentials() 157 | arr_api = None 158 | trakt_api = TraktAPI(oauth2_bearer, trakt_api_key, trakt_user, trakt_secret) 159 | if args.list: 160 | trakt_api.list = args.list 161 | if args.privacy: 162 | trakt_api.list_privacy = args.privacy 163 | if args.timeout: 164 | trakt_api.post_timeout = args.timeout 165 | if args.radarr or args.all or args.sonarr: 166 | arr_api = ArrAPI() 167 | 168 | if args.radarr or args.all: 169 | config.validate_arr_configuration(arr_api, trakt_api, "Radarr", args) 170 | tvdb_ids, tmdb_ids, imdb_ids, trakt_ids = trakt_api.get_list( 171 | args, arr_api.endpoint["Radarr"][0] 172 | ) 173 | arr_ids, arr_imdb, arr_data = arr_api.get_list(args, "Radarr") 174 | print("[Radarr]") 175 | trakt_api.add_to_list( 176 | args, 177 | arr_api.endpoint["Radarr"][2], 178 | arr_data, 179 | tmdb_ids, 180 | arr_api.endpoint["Radarr"][1], 181 | imdb_ids, 182 | arr_ids, 183 | arr_imdb, 184 | trakt_ids, 185 | ) 186 | print(f"Total Movies: {len(arr_ids)}\n") 187 | 188 | if args.sonarr or args.all: 189 | config.validate_arr_configuration(arr_api, trakt_api, "Sonarr", args) 190 | tvdb_ids, tmdb_ids, imdb_ids, trakt_ids = trakt_api.get_list( 191 | args, arr_api.endpoint["Sonarr"][2].rstrip("s") 192 | ) 193 | 194 | arr_ids, arr_imdb, arr_data = arr_api.get_list(args, "Sonarr") 195 | print("[Sonarr]") 196 | trakt_api.add_to_list( 197 | args, 198 | arr_api.endpoint["Sonarr"][2], 199 | arr_data, 200 | tvdb_ids, 201 | arr_api.endpoint["Sonarr"][1], 202 | imdb_ids, 203 | arr_ids, 204 | arr_imdb, 205 | trakt_ids, 206 | ) 207 | print(f"Total Series: {len(arr_ids)}") 208 | sys.exit(0) 209 | 210 | if args.radarr or args.all: 211 | sys.exit(0) 212 | 213 | parser.print_help() 214 | 215 | 216 | if __name__ == "__main__": 217 | main() 218 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup, find_packages 4 | 5 | """ 6 | setup file for package publishing 7 | """ 8 | # User-friendly description from README.md 9 | current_directory = os.path.dirname(os.path.abspath(__file__)) 10 | try: 11 | with open(os.path.join(current_directory, "README.md"), encoding="utf-8") as f: 12 | LONG_DESCRIPTION = f.read() 13 | except OSError: 14 | LONG_DESCRIPTION = "" 15 | 16 | try: 17 | with open( 18 | os.path.join(current_directory, f"retraktarr{os.path.sep}VERSION"), 19 | encoding="utf-8", 20 | ) as f: 21 | VERSION_NO = f.read() 22 | except OSError: 23 | VERSION_NO = "" 24 | 25 | 26 | with open("requirements.txt") as reqs_file: 27 | requirements = reqs_file.read().splitlines() 28 | 29 | setup( 30 | # Name of the package 31 | name="retraktarr", 32 | # Start with a small number and increase it with 33 | # every change you make https://semver.org 34 | # Chose a license from here: https: // 35 | # help.github.com / articles / licensing - a - 36 | # repository. For example: MIT 37 | license="MIT", 38 | version=VERSION_NO, 39 | # Short description of your library 40 | description="a simple Arr -> Trakt.tv list sync script", 41 | entry_points={"console_scripts": ["retraktarr = retraktarr:main"]}, 42 | # Long description of your library 43 | install_requires=requirements, 44 | long_description=LONG_DESCRIPTION, 45 | long_description_content_type="text/markdown", 46 | # long_description=long_description, 47 | # long_description_content_type="text/markdown", 48 | # Your name 49 | author="zakkarry", 50 | # Your email 51 | author_email="zak@ary.dev", 52 | # Either the link to your github or to your website 53 | url="https://github.com/zakkarry", 54 | # Link from which the project can be downloaded 55 | download_url="https://github.com/zakkarry/retraktarr", 56 | packages=find_packages(exclude=[".github"]), 57 | package_data={"retraktarr": ["VERSION"]}, 58 | ) 59 | --------------------------------------------------------------------------------