├── 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 | [](https://opensource.org/licenses/MIT)
4 | [](https://github.com/zakkarry/retraktarr/issues)
5 | [](https://github.com/zakkarry/retraktarr/pulls)
6 | [](https://github.com/zakkarry/retraktarr/stargazers)
7 | [](https://pypi.org/project/retraktarr/)
8 | [](https://www.python.org/downloads/)
9 | [](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 |
--------------------------------------------------------------------------------