├── README.md ├── debug.log ├── radarr_collection_sync.py ├── radarr_delete_list.py ├── radarr_missing_list_sync.py ├── requirements.txt ├── sonarr_collection_sync.py └── sonarr_trakt_connection.py /README.md: -------------------------------------------------------------------------------- 1 | # Trakt-Arr-Integration 2 | Python scripts to push data from Radarr/Sonarr into your Trakt collection. 3 | 4 | All 5 scripts require you to have Radarr aphrodite and have set up the Trakt connection in Radarr. 5 | 6 | Requires Python3 (developed with Python 3.8). You need the requests model, i.e. `python -m pip install requests` replacing python with whatever your python3 binary is. 7 | 8 | **Force Resync** will first delete ALL your movie or show data in your collections (depending on the script) before pushing the data from Sonarr/Radarr. The benefit is that this removes any possible entities in Trakt that are not in Sonarr, forcing them to be fully in sync. If False, it will update the relevant data for existing items while adding new items in Sonarr/Radarr but not Trakt. 9 | 10 | **Use Collected Date** will utilize the download date from Sonarr/Radarr when syncing the data to your Trakt collection. If True, it will replace existing items' dates with those in the Arrs. If False, it will keep any existing item dates, and if a new item is being added, then it will use the airdate/release date for that item instead. 11 | 12 | **Chunk Size** was recommended by the Trakt developer. It slows down the runtime for the syncs and increases API calls, but higher values can result in Trakt being unable to process your request. If you do a forced resync, then Trakt will handle larger chunk sizes and be generally faster as there is no validation or data matching happening. 13 | 14 | ## Desriptions of scripts 15 | 16 | 1. radarr_collection_sync will use Radarr data to upload downloaded movie info to Trakt 17 | 18 | 2. sonarr_collection_sync will use Sonarr data to upload downloaded episode info to Trakt 19 | 20 | 3. sonarr_trakt_connection is a temporary script that can be used as a connection in Sonarr, similar to the one in Radarr, to push new episode downloads into your Trakt collection to keep it up to date. A native connection PR is open at https://github.com/Sonarr/Sonarr/pull/3917 and should hopefully be added in the near future to make this script obsolete. 21 | 22 | 4. radarr_delete_list is a script that can be used as a connection in Radarr to delete a movie from either your watchlist or a custom list you made. The usecase is to keep a list of movies as "Wanted" in Trakt that is synced to the one in Radarr. If you use the Collection sync/connection, then you can easily filter movies you want, have, or are not in Radarr when navigating Trakt. 23 | 24 | 5. radarr_missing_list_sync will push all movies marked as missing in Radarr to a list or your watchlist. This can help get your list up to speed for script #4 to then keep in sync going forward. 25 | 26 | ## Limitations 27 | 28 | 1. Current metadata mapping is based on the available options in Trakt or the Arrs. It is fairly robust already and handles multiple edge cases; however, there are limitations. 29 | - Detailed HDR/3D info in the Arrs is not available, so we cannot map these in Trakt. 30 | - Trakt does not have a category for TV/PVR releases yet, so we map them to DVDs (might switch to VHS). 31 | - Trakt does not differentiate between TrueHD ATMOS and Dolby Digital Plus ATMOS, so both are mapped to ATMOS. 32 | - Trakt does not have an option for DTS-ES 33 | - Trakt does not differentiate between Vorbis and Opus OGG tracks 34 | - Trakt does not differentiate between MP3 and MP2 tracks 35 | 36 | 2. Radarr/Sonarr do not have an On Delete notification yet, though there is a PR open at https://github.com/Radarr/Radarr/pull/4869 to add this in Radarr that should hopefully be added soon. This means that deletions in the Arrs are not going to result in deletions in Trakt, so these have to be done manually or via script for now 37 | -------------------------------------------------------------------------------- /debug.log: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rg9400/Trakt-Arr-Integration/5fc074bf317a05f1db9848354b756afd83791349/debug.log -------------------------------------------------------------------------------- /radarr_collection_sync.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # Author: /u/RG9400 4 | # Requires: requests 5 | import requests 6 | import json 7 | import time 8 | 9 | ##################################### CONFIG BELOW ##################################### 10 | RADARR_API_KEY = "" 11 | RADARR_LOCAL_URL = 'http://localhost:7878/radarr/' # Make sure you include the trailing / 12 | TRAKT_FORCE_RESYNC = False #Set to True to delete all movies from Trakt's collection before pushing Radarr's list 13 | CHUNK_SIZE = 1000 #number of movies to send to Trakt in a single API paylod 14 | USE_RADARR_COLLECTED_DATE = True #If False, Trakt will use its existing collected date if available (won't work on resync) or the item's release date 15 | 16 | ######################################################################################## 17 | #We rely on Radarr for Trakt oAuth. This requires you to have a Trakt connection already setup in Radarr and authenticated 18 | #The below Trakt Client ID should not need to be changed, but you can verify if it is still accurate by checking the one found at 19 | #https://github.com/Radarr/Radarr/blob/aphrodite/src/NzbDrone.Core/Notifications/Trakt/TraktProxy.cs#L27 20 | TRAKT_CLIENT_ID = "64508a8bf370cee550dde4806469922fd7cd70afb2d5690e3ee7f75ae784b70e" 21 | ######################################################################################### 22 | 23 | 24 | ### CODE BELOW ### 25 | 26 | def divide_chunks(l, n): 27 | for i in range(0, len(l), n): 28 | yield l[i:i + n] 29 | 30 | radarr_notifications_url = '{}api/v3/notification?apikey={}'.format(RADARR_LOCAL_URL, RADARR_API_KEY) 31 | radarr_notifications = requests.get(radarr_notifications_url).json() 32 | trakt_notification = next(notification for notification in radarr_notifications if notification['implementation'] == "Trakt") 33 | access_token = next(token for token in trakt_notification['fields'] if token['name'] == "accessToken") 34 | TRAKT_BEARER_TOKEN = access_token['value'] 35 | 36 | trakt_api_url = 'https://api.trakt.tv/sync/collection' 37 | trakt_headers = {"Content-Type": "application/json", "Authorization": "Bearer {}".format(TRAKT_BEARER_TOKEN), 38 | "trakt-api-version": "2", "trakt-api-key": TRAKT_CLIENT_ID, 'User-Agent': 'Radarr Trakt Collection Syncer v0.1'} 39 | 40 | if TRAKT_FORCE_RESYNC: 41 | deletion_list = [] 42 | print('Removing all movies from your Trakt collection to start fresh') 43 | trakt_movies = requests.get('{}/movies'.format(trakt_api_url), headers=trakt_headers).json() 44 | for movie in trakt_movies: 45 | deletion_list.append(movie['movie']) 46 | chunked_deletion_list = divide_chunks(deletion_list, CHUNK_SIZE) 47 | for deletion_sublist in chunked_deletion_list: 48 | deletion_payload = json.dumps({"movies":deletion_sublist}) 49 | trakt_delete_response = requests.post('{}/remove'.format(trakt_api_url), headers=trakt_headers, data=deletion_payload) 50 | print("HTTP Response Code: {}".format(trakt_delete_response.status_code)) 51 | message = trakt_delete_response.json() 52 | print("Response: {}".format(json.dumps(message, sort_keys=True, indent=4, separators=(',', ': ')))) 53 | time.sleep(1) 54 | 55 | radarr_api_url = '{}api/v3/movie?apikey={}'.format(RADARR_LOCAL_URL, RADARR_API_KEY) 56 | radarr_movies = requests.get(radarr_api_url).json() 57 | 58 | movie_list = [] 59 | print('Pushing all downloaded movies from Radarr into your Trakt collection') 60 | downloaded_movies = (movie for movie in radarr_movies if movie['sizeOnDisk'] > 0) 61 | for movie in downloaded_movies: 62 | title = movie['title'] 63 | year = movie.get('year', None) 64 | imdb_id = movie.get('imdbId', None) 65 | tmdb_id = movie['tmdbId'] 66 | 67 | source = movie['movieFile']['quality']['quality']['source'] 68 | source_mapping = { 69 | "webdl":"digital", 70 | "webrip":"digital", 71 | "bluray":"bluray", 72 | "tv":"dvd", 73 | "dvd":"dvd" 74 | } 75 | media_type = source_mapping.get(source, None) 76 | 77 | radarr_resolution = movie['movieFile']['quality']['quality']['resolution'] 78 | scan_type = movie['movieFile']['mediaInfo']['scanType'] 79 | resolution_mapping = { 80 | 2160:"uhd_4k", 81 | 1080:"hd_1080", 82 | 720:"hd_720p", 83 | 480:"sd_480", 84 | 576:"sd_576" 85 | } 86 | resolution = resolution_mapping.get(radarr_resolution, None) 87 | if resolution in ["hd_1080", "sd_480", "sd_576"]: 88 | if scan_type in ['Interlaced', 'MBAFF', 'PAFF']: 89 | resolution = '{}i'.format(resolution) 90 | else: 91 | resolution = '{}p'.format(resolution) 92 | 93 | audio_codec = movie['movieFile']['mediaInfo']['audioCodec'] 94 | audio_mapping = { 95 | "AC3":"dolby_digital", 96 | "EAC3":"dolby_digital_plus", 97 | "TrueHD":"dolby_truehd", 98 | "EAC3 Atmos":"dolby_digital_plus_atmos", 99 | "TrueHD Atmos":"dolby_atmos", 100 | "DTS":"dts", 101 | "DTS-ES":"dts", 102 | "DTS-HD MA":"dts_ma", 103 | "DTS-HD HRA":"dts_hr", 104 | "DTS-X":"dts_x", 105 | "MP3":"mp3", 106 | "MP2":"mp2", 107 | "Vorbis":"ogg", 108 | "WMA":"wma", 109 | "AAC":"aac", 110 | "PCM":"lpcm", 111 | "FLAC":"flac", 112 | "Opus":"ogg_opus" 113 | } 114 | audio = audio_mapping.get(audio_codec, None) 115 | 116 | audio_channel_count = movie['movieFile']['mediaInfo']['audioChannels'] 117 | channel_mapping = str(audio_channel_count) 118 | audio_channels = channel_mapping 119 | 120 | media_object = { 121 | "title": title, 122 | "year": year, 123 | "ids": { 124 | "imdb": imdb_id, 125 | "tmdb": tmdb_id 126 | }, 127 | "media_type": media_type, 128 | "resolution": resolution, 129 | "audio": audio, 130 | "audio_channels": audio_channels 131 | } 132 | if USE_RADARR_COLLECTED_DATE: 133 | collected_at = movie['movieFile']['dateAdded'] 134 | media_object["collected_at"] = collected_at 135 | 136 | movie_list.append(media_object) 137 | 138 | chunked_movie_list = divide_chunks(movie_list, CHUNK_SIZE) 139 | for movie_sublist in chunked_movie_list: 140 | payload = json.dumps({"movies": movie_sublist}) 141 | trakt_response = requests.post(trakt_api_url, headers=trakt_headers, data=payload) 142 | print("HTTP Response Code: {}".format(trakt_response.status_code)) 143 | message = trakt_response.json() 144 | print("Response: {}".format(json.dumps(message, sort_keys=True, indent=4, separators=(',', ': ')))) 145 | time.sleep(1) 146 | -------------------------------------------------------------------------------- /radarr_delete_list.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from logging.handlers import RotatingFileHandler 3 | from logging import DEBUG, INFO, getLogger, Formatter 4 | import requests 5 | import json 6 | import os 7 | import sys 8 | 9 | from requests.api import delete 10 | 11 | ##################################### CONFIG BELOW ##################################### 12 | RADARR_API_KEY = "" 13 | RADARR_LOCAL_URL = 'http://localhost:7878/radarr/' # Make sure you include the trailing / 14 | CUSTOM_LIST = '' # Leave blank if not deleting from a custom list, else set to the trakt slug/ID (from the URL of the list) 15 | DELETE_FROM_WATCHLIST = True # Set to False to not delete from watchlist 16 | ######################################################################################## 17 | #The below Trakt Client ID should not need to be changed, but you can verify if it is still accurate by checking the one found at 18 | #https://github.com/Radarr/Radarr/blob/aphrodite/src/NzbDrone.Core/Notifications/Trakt/TraktProxy.cs#L27 19 | TRAKT_CLIENT_ID = "64508a8bf370cee550dde4806469922fd7cd70afb2d5690e3ee7f75ae784b70e" 20 | ######################################################################################### 21 | 22 | 23 | ### CODE BELOW ### 24 | # Set up the rotating log files 25 | size = 10*1024*1024 # 5MB 26 | max_files = 5 # Keep up to 7 logs 27 | log_filename = os.path.join(os.path.dirname(sys.argv[0]), 'radarr_notification.log') 28 | file_logger = RotatingFileHandler(log_filename, maxBytes=size, backupCount=max_files) 29 | file_logger.setLevel(INFO) 30 | logger_formatter = Formatter('[%(asctime)s] %(name)s - %(levelname)s - %(message)s') 31 | file_logger.setFormatter(logger_formatter) 32 | log = getLogger('Trakt') 33 | log.setLevel(INFO) 34 | log.addHandler(file_logger) 35 | 36 | # Check if test from Sonarr, mark succeeded and exit 37 | eventtype = os.environ.get('radarr_eventtype') 38 | if eventtype == 'Test': 39 | log.info('Radarr script test succeeded.') 40 | sys.exit(0) 41 | 42 | radarr_notifications_url = '{}api/v3/notification?apikey={}'.format(RADARR_LOCAL_URL, RADARR_API_KEY) 43 | radarr_notifications = requests.get(radarr_notifications_url).json() 44 | trakt_notification = next(notification for notification in radarr_notifications if notification['implementation'] == "Trakt") 45 | access_token = next(token for token in trakt_notification['fields'] if token['name'] == "accessToken") 46 | auth_user = next(token for token in trakt_notification['fields'] if token['name'] == "authUser")['value'] 47 | TRAKT_BEARER_TOKEN = access_token['value'] 48 | 49 | imdb_id = os.environ.get('radarr_movie_imdbid') 50 | tmdb_id = os.environ.get('radarr_movie_tmdbid') 51 | 52 | trakt_watchlist_url = 'https://api.trakt.tv/sync/watchlist/remove' 53 | trakt_list_url = 'https://api.trakt.tv/users/{}/lists/{}/items/remove'.format(auth_user, CUSTOM_LIST) 54 | trakt_headers = {"Content-Type": "application/json", "Authorization": "Bearer {}".format(TRAKT_BEARER_TOKEN), 55 | "trakt-api-version": "2", "trakt-api-key": TRAKT_CLIENT_ID, 'User-Agent': 'Radarr Trakt Connection v0.1'} 56 | 57 | delete_data = [{"ids":{}}] 58 | delete_data[0]['ids']['tmdb'] = tmdb_id 59 | if imdb_id: 60 | delete_data[0]['ids']['imdb'] = imdb_id 61 | 62 | message = {"movies": delete_data} 63 | payload = json.dumps(message) 64 | log.info("Payload: {}".format(json.dumps(message, sort_keys=True, indent=4, separators=(',', ': ')))) 65 | if DELETE_FROM_WATCHLIST: 66 | watchlist_response = requests.post(trakt_watchlist_url, headers=trakt_headers, data=payload) 67 | log.info("HTTP Response Code: {}".format(watchlist_response.status_code)) 68 | watchlist_message = watchlist_response.json() 69 | log.info("Response: {}".format(json.dumps(watchlist_message, sort_keys=True, indent=4, separators=(',', ': ')))) 70 | if CUSTOM_LIST: 71 | list_response = requests.post(trakt_list_url, headers=trakt_headers, data=payload) 72 | log.info("HTTP Response Code: {}".format(list_response.status_code)) 73 | list_message = list_response.json() 74 | log.info("Response: {}".format(json.dumps(list_message, sort_keys=True, indent=4, separators=(',', ': ')))) 75 | -------------------------------------------------------------------------------- /radarr_missing_list_sync.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | import time 4 | 5 | ##################################### CONFIG BELOW ##################################### 6 | RADARR_API_KEY = "" 7 | RADARR_LOCAL_URL = 'http://localhost:7878/radarr/' # Make sure you include the trailing / 8 | CUSTOM_LIST = '' # Leave blank if not syncing to a custom list, else set to the trakt slug/ID (from the URL of the list) 9 | SYNC_WATCHLIST = True # Set to False to not sync to your watchlist 10 | FORCE_RESYNC = False # Set to True to delete everything on the lists prior to pushing 11 | CHUNK_SIZE = 1000 12 | ######################################################################################## 13 | #The below Trakt Client ID should not need to be changed, but you can verify if it is still accurate by checking the one found at 14 | #https://github.com/Radarr/Radarr/blob/aphrodite/src/NzbDrone.Core/Notifications/Trakt/TraktProxy.cs#L27 15 | TRAKT_CLIENT_ID = "64508a8bf370cee550dde4806469922fd7cd70afb2d5690e3ee7f75ae784b70e" 16 | ######################################################################################### 17 | 18 | 19 | ### CODE BELOW ### 20 | def divide_chunks(l, n): 21 | for i in range(0, len(l), n): 22 | yield l[i:i + n] 23 | 24 | radarr_notifications_url = '{}api/v3/notification?apikey={}'.format(RADARR_LOCAL_URL, RADARR_API_KEY) 25 | radarr_notifications = requests.get(radarr_notifications_url).json() 26 | trakt_notification = next(notification for notification in radarr_notifications if notification['implementation'] == "Trakt") 27 | access_token = next(token for token in trakt_notification['fields'] if token['name'] == "accessToken") 28 | auth_user = next(token for token in trakt_notification['fields'] if token['name'] == "authUser")['value'] 29 | TRAKT_BEARER_TOKEN = access_token['value'] 30 | 31 | trakt_watchlist_url = 'https://api.trakt.tv/sync/watchlist' 32 | trakt_list_url = 'https://api.trakt.tv/users/{}/lists/{}/items'.format(auth_user, CUSTOM_LIST) 33 | trakt_headers = {"Content-Type": "application/json", "Authorization": "Bearer {}".format(TRAKT_BEARER_TOKEN), 34 | "trakt-api-version": "2", "trakt-api-key": TRAKT_CLIENT_ID, 'User-Agent': 'Radarr Trakt Connection v0.1'} 35 | 36 | if FORCE_RESYNC: 37 | if CUSTOM_LIST: 38 | print("Removing all movies from list {} for user {} to start fresh".format(CUSTOM_LIST, auth_user)) 39 | deletion_list = [] 40 | trakt_movies = requests.get('{}/movies'.format(trakt_list_url), headers=trakt_headers).json() 41 | for movie in trakt_movies: 42 | deletion_list.append(movie['movie']) 43 | chunked_deletion_list = divide_chunks(deletion_list, CHUNK_SIZE) 44 | for deletion_sublist in chunked_deletion_list: 45 | deletion_payload = json.dumps({"movies": deletion_sublist}) 46 | trakt_delete_response = requests.post('{}/remove'.format(trakt_list_url), headers=trakt_headers, data=deletion_payload) 47 | print("HTTP Response Code: {}".format(trakt_delete_response.status_code)) 48 | message = trakt_delete_response.json() 49 | print("Response: {}".format(json.dumps(message, sort_keys=True, indent=4, separators=(',', ': ')))) 50 | time.sleep(1) 51 | 52 | if SYNC_WATCHLIST: 53 | print("Removing all movies from watchlist for user {} to start fresh".format(auth_user)) 54 | deletion_list = [] 55 | trakt_movies = requests.get('{}/movies'.format(trakt_watchlist_url), headers=trakt_headers).json() 56 | for movie in trakt_movies: 57 | deletion_list.append(movie['movie']) 58 | chunked_deletion_list = divide_chunks(deletion_list, CHUNK_SIZE) 59 | for deletion_sublist in chunked_deletion_list: 60 | deletion_payload = json.dumps({"movies": deletion_sublist}) 61 | trakt_delete_response = requests.post('{}/remove'.format(trakt_watchlist_url), headers=trakt_headers, data=deletion_payload) 62 | print("HTTP Response Code: {}".format(trakt_delete_response.status_code)) 63 | message = trakt_delete_response.json() 64 | print("Response: {}".format(json.dumps(message, sort_keys=True, indent=4, separators=(',', ': ')))) 65 | time.sleep(1) 66 | 67 | radarr_api_url = '{}api/v3/movie?apikey={}'.format(RADARR_LOCAL_URL, RADARR_API_KEY) 68 | radarr_movies = requests.get(radarr_api_url).json() 69 | sync_list = (movie for movie in radarr_movies if movie['hasFile'] == False) 70 | 71 | movie_list = [] 72 | for movie in sync_list: 73 | movie_object = {"ids":{}} 74 | imdb_id = movie['imdbId'] 75 | if imdb_id: 76 | movie_object['ids']['imdb'] = imdb_id 77 | movie_object['ids']['tmdb'] = movie['tmdbId'] 78 | movie_object['title'] = movie['title'] 79 | movie_object['year'] = movie['year'] 80 | movie_list.append(movie_object) 81 | 82 | chunked_movie_list = divide_chunks(movie_list, CHUNK_SIZE) 83 | print("Pushing all missing movies into your requested lists") 84 | for movie_sublist in chunked_movie_list: 85 | message = {"movies": movie_sublist} 86 | payload = json.dumps(message) 87 | if CUSTOM_LIST: 88 | list_response = requests.post(trakt_list_url, headers=trakt_headers, data=payload) 89 | print("HTTP Response Code: {}".format(list_response.status_code)) 90 | list_message = list_response.json() 91 | print("Response: {}".format(json.dumps(list_message, sort_keys=True, indent=4, separators=(',', ': ')))) 92 | time.sleep(1) 93 | if SYNC_WATCHLIST: 94 | watchlist_response = requests.post(trakt_watchlist_url, headers=trakt_headers, data=payload) 95 | print("HTTP Response Code: {}".format(watchlist_response.status_code)) 96 | watchlist_message = watchlist_response.json() 97 | print("Response: {}".format(json.dumps(watchlist_message, sort_keys=True, indent=4, separators=(',', ': ')))) 98 | time.sleep(1) 99 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | -------------------------------------------------------------------------------- /sonarr_collection_sync.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # Author: /u/RG9400 4 | # Requires: requests 5 | import requests 6 | import json 7 | import time 8 | 9 | ##################################### CONFIG BELOW ##################################### 10 | SONARR_API_KEY = "" 11 | SONARR_LOCAL_URL = 'http://localhost:8989/sonarr/' # Make sure you include the trailing / 12 | TRAKT_FORCE_RESYNC = False #Set to True to delete all movies from Trakt's collection before pushing Sonarr's list 13 | CHUNK_SIZE = 1000 #number of items to send to Trakt to send to Trakt in a single API payload 14 | USE_SONARR_COLLECTED_DATE = True #If False, Trakt will use its existing collected date if available (won't work on resync) or the item's release date 15 | 16 | #We rely on Radarr for Trakt oAuth. This requires you to have a Trakt connection already setup in Radarr and authenticated 17 | RADARR_API_KEY = "" 18 | RADARR_LOCAL_URL = 'http://localhost:7878/radarr/' # Make sure you include the trailing / 19 | 20 | ######################################################################################## 21 | #The below Trakt Client ID should not need to be changed, but you can verify if it is still accurate by checking the one found at 22 | #https://github.com/Radarr/Radarr/blob/aphrodite/src/NzbDrone.Core/Notifications/Trakt/TraktProxy.cs#L27 23 | TRAKT_CLIENT_ID = "64508a8bf370cee550dde4806469922fd7cd70afb2d5690e3ee7f75ae784b70e" 24 | ######################################################################################### 25 | 26 | 27 | ### CODE BELOW ### 28 | 29 | def divide_chunks(l, n): 30 | for i in range(0, len(l), n): 31 | yield l[i:i + n] 32 | 33 | def split_by_nested_count(lst, key_1, key_2, n): 34 | collected = [] 35 | i = 0 36 | for d in lst: 37 | count = 0 38 | for e in d[key_1]: 39 | count += len(e[key_2]) 40 | if i + count > n: 41 | yield collected 42 | collected = [] 43 | collected.append(d) 44 | i = count 45 | else: 46 | i += count 47 | collected.append(d) 48 | if collected: # yield any remainder 49 | yield collected 50 | 51 | def find(lst, key, value): 52 | for i, dic in enumerate(lst): 53 | if dic[key] == value: 54 | return i 55 | return None 56 | 57 | radarr_notifications_url = '{}api/v3/notification?apikey={}'.format(RADARR_LOCAL_URL, RADARR_API_KEY) 58 | radarr_notifications = requests.get(radarr_notifications_url).json() 59 | trakt_notification = next(notification for notification in radarr_notifications if notification['implementation'] == "Trakt") 60 | access_token = next(token for token in trakt_notification['fields'] if token['name'] == "accessToken") 61 | TRAKT_BEARER_TOKEN = access_token['value'] 62 | 63 | trakt_api_url = 'https://api.trakt.tv/sync/collection' 64 | trakt_headers = {"Content-Type": "application/json", "Authorization": "Bearer {}".format(TRAKT_BEARER_TOKEN), 65 | "trakt-api-version": "2", "trakt-api-key": TRAKT_CLIENT_ID, 'User-Agent': 'Sonarr Trakt Collection Syncer v0.1'} 66 | 67 | if TRAKT_FORCE_RESYNC: 68 | deletion_list = [] 69 | print('Removing all shows from your Trakt collection to start fresh') 70 | trakt_shows = requests.get('{}/shows'.format(trakt_api_url), headers=trakt_headers).json() 71 | for show in trakt_shows: 72 | deletion_list.append(show['show']) 73 | chunked_deletion_list = divide_chunks(deletion_list, CHUNK_SIZE) 74 | for deletion_sublist in chunked_deletion_list: 75 | deletion_payload = json.dumps({"shows":deletion_sublist}) 76 | trakt_delete_response = requests.post('{}/remove'.format(trakt_api_url), headers=trakt_headers, data=deletion_payload) 77 | print("HTTP Response Code: {}".format(trakt_delete_response.status_code)) 78 | message = trakt_delete_response.json() 79 | print("Response: {}".format(json.dumps(message, sort_keys=True, indent=4, separators=(',', ': ')))) 80 | time.sleep(1) 81 | 82 | sess = requests.Session() 83 | 84 | sonarr_series_api_url = '{}api/v3/series?apikey={}'.format(SONARR_LOCAL_URL, SONARR_API_KEY) 85 | sonarr_series = sess.get(sonarr_series_api_url).json() 86 | 87 | series_list = [] 88 | print('Pushing all downloaded episodes from Sonarr into your Trakt collection') 89 | downloaded_series = (series for series in sonarr_series if series.get('statistics', {}).get('sizeOnDisk', 0) > 0) 90 | for series in downloaded_series: 91 | title = series['title'] 92 | year = series.get('year', None) 93 | imdb_id = series.get('imdbId', None) 94 | tvdb_id = series['tvdbId'] 95 | series_id = series['id'] 96 | 97 | sonarr_episode_api_url = '{}api/v3/episode?seriesId={}&apikey={}'.format(SONARR_LOCAL_URL, series_id, SONARR_API_KEY) 98 | sonarr_episodes = sess.get(sonarr_episode_api_url).json() 99 | downloaded_episodes = (episode for episode in sonarr_episodes if episode['episodeFileId'] != 0) 100 | season_list = [] 101 | 102 | for episode in downloaded_episodes: 103 | season_number = episode['seasonNumber'] 104 | episode_number = episode['episodeNumber'] 105 | episode_file_id = episode['episodeFileId'] 106 | 107 | sonarr_episode_file_api_url = '{}api/v3/episodefile/{}?apikey={}'.format(SONARR_LOCAL_URL, episode_file_id, SONARR_API_KEY) 108 | episode_file = sess.get(sonarr_episode_file_api_url).json() 109 | 110 | source = episode_file['quality']['quality']['source'] 111 | source_mapping = { 112 | "web":"digital", 113 | "webRip":"digital", 114 | "blurayRaw":"bluray", 115 | "bluray":"bluray", 116 | "television":"dvd", 117 | "televisionRaw":"dvd", 118 | "dvd":"dvd" 119 | } 120 | media_type = source_mapping.get(source, None) 121 | 122 | sonarr_resolution = episode_file['quality']['quality']['resolution'] 123 | scan_type = episode_file['mediaInfo']['scanType'] 124 | resolution_mapping = { 125 | 2160:"uhd_4k", 126 | 1080:"hd_1080", 127 | 720:"hd_720p", 128 | 480:"sd_480", 129 | 576:"sd_576" 130 | } 131 | resolution = resolution_mapping.get(sonarr_resolution, None) 132 | if resolution in ["hd_1080", "sd_480", "sd_576"]: 133 | if scan_type in ['Interlaced', 'MBAFF', 'PAFF']: 134 | resolution = '{}i'.format(resolution) 135 | else: 136 | resolution = '{}p'.format(resolution) 137 | 138 | audio_codec = episode_file['mediaInfo']['audioCodec'] 139 | audio_mapping = { 140 | "AC3":"dolby_digital", 141 | "EAC3":"dolby_digital_plus", 142 | "TrueHD":"dolby_truehd", 143 | "EAC3 Atmos":"dolby_digital_plus_atmos", 144 | "TrueHD Atmos":"dolby_atmos", 145 | "DTS":"dts", 146 | "DTS-ES":"dts", 147 | "DTS-HD MA":"dts_ma", 148 | "DTS-HD HRA":"dts_hr", 149 | "DTS-X":"dts_x", 150 | "MP3":"mp3", 151 | "MP2":"mp2", 152 | "Vorbis":"ogg", 153 | "WMA":"wma", 154 | "AAC":"aac", 155 | "PCM":"lpcm", 156 | "FLAC":"flac", 157 | "Opus":"ogg_opus" 158 | } 159 | audio = audio_mapping.get(audio_codec, None) 160 | 161 | audio_channel_count = episode_file['mediaInfo']['audioChannels'] 162 | channel_mapping = str(audio_channel_count) 163 | audio_channels = channel_mapping 164 | #Below is when DTS-X is used and MediaInfo does not give object counts 165 | if audio_channel_count == 8.0: 166 | audio_channels = "7.1" 167 | #Below incorrect count can sometimes be 6.1 for DTS-HR tracks, but the vast majority of time, it is for 7.1 tracks 168 | #It happens when channels_original and channels differ. Out of 17 such cases, only 3 were actually 6.1, rest were 7.1 169 | elif audio_channel_count == 6.0 and audio == "dts_ma": 170 | audio_channels = "7.1" 171 | elif audio_channel_count == 6.0 and audio != "dts_ma": 172 | audio_channels = "6.1" 173 | #Not sure why this happens, but I noticed a few older 1.0 PCM tracks coming as 0.0 channel count in Radarr 174 | elif audio_channel_count == 0.0: 175 | audio_channels = "1.0" 176 | 177 | episode_info = { 178 | "number": episode_number, 179 | "media_type": media_type, 180 | "resolution": resolution, 181 | "audio": audio, 182 | "audio_channels": audio_channels 183 | } 184 | 185 | if USE_SONARR_COLLECTED_DATE: 186 | collected_at = episode_file['dateAdded'] 187 | episode_info['collected_at'] = collected_at 188 | 189 | if not any(season['number'] == season_number for season in season_list): 190 | season_list.append({ 191 | "number": season_number, 192 | "episodes": [episode_info] 193 | }) 194 | else: 195 | index_number = find(season_list, 'number', season_number) 196 | season_list[index_number]['episodes'].append(episode_info) 197 | 198 | media_object = { 199 | "title": title, 200 | "year": year, 201 | "ids": { 202 | "imdb": imdb_id, 203 | "tvdb": tvdb_id 204 | }, 205 | "seasons": season_list 206 | } 207 | series_list.append(media_object) 208 | 209 | chunked_series_list = split_by_nested_count(series_list, 'seasons', 'episodes', CHUNK_SIZE) 210 | for series_sublist in chunked_series_list: 211 | payload = json.dumps({"shows": series_sublist}) 212 | trakt_response = requests.post(trakt_api_url, headers=trakt_headers, data=payload) 213 | print("HTTP Response Code: {}".format(trakt_response.status_code)) 214 | message = trakt_response.json() 215 | print("Response: {}".format(json.dumps(message, sort_keys=True, indent=4, separators=(',', ': ')))) 216 | time.sleep(1) 217 | -------------------------------------------------------------------------------- /sonarr_trakt_connection.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # Author: /u/RG9400 4 | # Requires: requests 5 | from datetime import datetime 6 | from logging.handlers import RotatingFileHandler 7 | from logging import DEBUG, INFO, getLogger, Formatter 8 | import requests 9 | import json 10 | import os 11 | import sys 12 | 13 | ##################################### CONFIG BELOW ##################################### 14 | SONARR_API_KEY = "" 15 | SONARR_LOCAL_URL = 'http://localhost:8989/sonarr/' # Make sure you include the trailing / 16 | 17 | #We rely on Radarr for Trakt oAuth. This requires you to have a Trakt connection already setup in Radarr and authenticated 18 | RADARR_API_KEY = "" 19 | RADARR_LOCAL_URL = 'http://localhost:7878/radarr/' # Make sure you include the trailing / 20 | 21 | ######################################################################################## 22 | #The below Trakt Client ID should not need to be changed, but you can verify if it is still accurate by checking the one found at 23 | #https://github.com/Radarr/Radarr/blob/aphrodite/src/NzbDrone.Core/Notifications/Trakt/TraktProxy.cs#L27 24 | TRAKT_CLIENT_ID = "64508a8bf370cee550dde4806469922fd7cd70afb2d5690e3ee7f75ae784b70e" 25 | ######################################################################################### 26 | 27 | 28 | ### CODE BELOW ### 29 | # Set up the rotating log files 30 | size = 10*1024*1024 # 5MB 31 | max_files = 5 # Keep up to 7 logs 32 | log_filename = os.path.join(os.path.dirname(sys.argv[0]), 'sonarr_trakt_connection.log') 33 | file_logger = RotatingFileHandler(log_filename, maxBytes=size, backupCount=max_files) 34 | file_logger.setLevel(INFO) 35 | logger_formatter = Formatter('[%(asctime)s] %(name)s - %(levelname)s - %(message)s') 36 | file_logger.setFormatter(logger_formatter) 37 | log = getLogger('Trakt') 38 | log.setLevel(INFO) 39 | log.addHandler(file_logger) 40 | 41 | # Check if test from Sonarr, mark succeeded and exit 42 | eventtype = os.environ.get('sonarr_eventtype') 43 | if eventtype == 'Test': 44 | log.info('Sonarr script test succeeded.') 45 | sys.exit(0) 46 | 47 | radarr_notifications_url = '{}api/v3/notification?apikey={}'.format(RADARR_LOCAL_URL, RADARR_API_KEY) 48 | radarr_notifications = requests.get(radarr_notifications_url).json() 49 | trakt_notification = next(notification for notification in radarr_notifications if notification['implementation'] == "Trakt") 50 | access_token = next(token for token in trakt_notification['fields'] if token['name'] == "accessToken") 51 | TRAKT_BEARER_TOKEN = access_token['value'] 52 | 53 | season_number = os.environ.get('sonarr_episodefile_seasonnumber') 54 | episode_numbers = os.environ.get('sonarr_episodefile_episodenumbers') 55 | tvdb_id = os.environ.get('sonarr_series_tvdbid') 56 | scene_name = os.environ.get('sonarr_episodefile_scenename') 57 | title = os.environ.get('sonarr_series_title') 58 | imdb_id = os.environ.get('sonarr_series_imdbid') 59 | episodefile_id = os.environ.get('sonarr_episodefile_id') 60 | series_id = os.environ.get('sonarr_series_id') 61 | 62 | def utc_now_iso(): 63 | utcnow = datetime.utcnow() 64 | return utcnow.isoformat() 65 | 66 | episodes = episode_numbers.split(",") 67 | 68 | trakt_api_url = 'https://api.trakt.tv/sync/collection' 69 | trakt_headers = {"Content-Type": "application/json", "Authorization": "Bearer {}".format(TRAKT_BEARER_TOKEN), 70 | "trakt-api-version": "2", "trakt-api-key": TRAKT_CLIENT_ID, 'User-Agent': 'Sonarr Trakt Connection v0.1'} 71 | 72 | sonarr_api_url = '{}api/v3/episodefile/{}?apikey={}'.format(SONARR_LOCAL_URL, episodefile_id, SONARR_API_KEY) 73 | episode_file = requests.get(sonarr_api_url).json() 74 | 75 | sonarr_series_api_url = '{}api/v3/series/{}?apikey={}'.format(SONARR_LOCAL_URL, series_id, SONARR_API_KEY) 76 | sonarr_series_data = requests.get(sonarr_series_api_url).json() 77 | 78 | year = sonarr_series_data.get('year', None) 79 | 80 | source = episode_file['quality']['quality']['source'] 81 | source_mapping = { 82 | "web":"digital", 83 | "webRip":"digital", 84 | "blurayRaw":"bluray", 85 | "bluray":"bluray", 86 | "television":"dvd", 87 | "televisionRaw":"dvd", 88 | "dvd":"dvd" 89 | } 90 | media_type = source_mapping.get(source, None) 91 | 92 | sonarr_resolution = episode_file['quality']['quality']['resolution'] 93 | scan_type = episode_file['mediaInfo']['scanType'] 94 | resolution_mapping = { 95 | 2160:"uhd_4k", 96 | 1080:"hd_1080", 97 | 720:"hd_720p", 98 | 480:"sd_480", 99 | 576:"sd_576" 100 | } 101 | resolution = resolution_mapping.get(sonarr_resolution, None) 102 | if resolution in ["hd_1080", "sd_480", "sd_576"]: 103 | if scan_type in ['Interlaced', 'MBAFF', 'PAFF']: 104 | resolution = '{}i'.format(resolution) 105 | else: 106 | resolution = '{}p'.format(resolution) 107 | 108 | audio_codec = episode_file['mediaInfo']['audioCodec'] 109 | audio_mapping = { 110 | "AC3":"dolby_digital", 111 | "EAC3":"dolby_digital_plus", 112 | "TrueHD":"dolby_truehd", 113 | "EAC3 Atmos":"dolby_digital_plus_atmos", 114 | "TrueHD Atmos":"dolby_atmos", 115 | "DTS":"dts", 116 | "DTS-ES":"dts", 117 | "DTS-HD MA":"dts_ma", 118 | "DTS-HD HRA":"dts_hr", 119 | "DTS-X":"dts_x", 120 | "MP3":"mp3", 121 | "MP2":"mp2", 122 | "Vorbis":"ogg", 123 | "WMA":"wma", 124 | "AAC":"aac", 125 | "PCM":"lpcm", 126 | "FLAC":"flac", 127 | "Opus":"ogg_opus" 128 | } 129 | audio = audio_mapping.get(audio_codec, None) 130 | 131 | audio_channel_count = episode_file['mediaInfo']['audioChannels'] 132 | channel_mapping = str(audio_channel_count) 133 | audio_channels = channel_mapping 134 | #Below is when DTS-X is used and MediaInfo does not give object counts 135 | if audio_channel_count == 8.0: 136 | audio_channels = "7.1" 137 | #Below incorrect count can sometimes be 6.1 for DTS-HR tracks, but the vast majority of time, it is for 7.1 tracks 138 | #It happens when channels_original and channels differ. Out of 17 such cases, only 3 were actually 6.1, rest were 7.1 139 | elif audio_channel_count == 6.0 and audio == "dts_ma": 140 | audio_channels = "7.1" 141 | elif audio_channel_count == 6.0 and audio != "dts_ma": 142 | audio_channels = "6.1" 143 | #Not sure why this happens, but I noticed a few older 1.0 PCM tracks coming as 0.0 channel count in Radarr 144 | elif audio_channel_count == 0.0: 145 | audio_channels = "1.0" 146 | 147 | collected_at = utc_now_iso() 148 | 149 | episode_list = [] 150 | for episode in episodes: 151 | episode_info = { 152 | "number": int(episode), 153 | "collected_at": collected_at, 154 | "media_type": media_type, 155 | "resolution": resolution, 156 | "audio": audio, 157 | "audio_channels": audio_channels 158 | } 159 | episode_list.append(episode_info) 160 | 161 | media_object = { 162 | "title": title, 163 | "year": year, 164 | "ids": { 165 | "imdb": imdb_id, 166 | "tvdb": tvdb_id 167 | }, 168 | "seasons": [ 169 | { 170 | "number": int(season_number), 171 | "episodes": episode_list 172 | } 173 | ] 174 | } 175 | show_list = [] 176 | show_list.append(media_object) 177 | message = {"shows": show_list} 178 | payload = json.dumps(message) 179 | log.info("Payload: {}".format(json.dumps(message, sort_keys=True, indent=4, separators=(',', ': ')))) 180 | trakt_response = requests.post(trakt_api_url, headers=trakt_headers, data=payload) 181 | log.info("HTTP Response Code: {}".format(trakt_response.status_code)) 182 | message = trakt_response.json() 183 | log.info("Response: {}".format(json.dumps(message, sort_keys=True, indent=4, separators=(',', ': ')))) 184 | --------------------------------------------------------------------------------