├── screenshots ├── season summary.png └── season titles.png ├── README.md └── trakt_seasons.py /screenshots/season summary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rg9400/Trakt-Season-Integration/HEAD/screenshots/season summary.png -------------------------------------------------------------------------------- /screenshots/season titles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rg9400/Trakt-Season-Integration/HEAD/screenshots/season titles.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plextrakt-Season-Integration 2 | Script to pull season data such as titles and summaries from Trakt into Plex. The goal was to be extremely flexible, so there are numerous flags available when calling the script. This should give granularity so that users can get the script to behave how they want. 3 | 4 | Python3+ only (developed with Python 3.8) 5 | 6 | **Requires plexapi 4.4.1 or higher and requests. Run `python -m pip install --upgrade plexapi requests` with python replaced with whatever your python3 binary is to make sure you have the latest one.** 7 | 8 | The script has to make two calls to Trakt per show, so the number of requests can add up. Please be respectful of their API servers, and configure the script to not constantly rescrape your entire Plex library continously. 9 | 10 | Note that the data is only as good as what is available in Trakt. If Trakt has poor summaries, those will get added still. 11 | 12 | Now supports libraries using the TMDB agent as well, but I cannot vouch for the data as it is entirely feasible that the seasons TMDB and Trakt are using vary widely. 13 | 14 | See examples of this data in Plex below: 15 | 16 | ### Screenshots 17 |
Expand 18 |

19 | 20 | 21 |

22 |
23 | 24 | ## File Config 25 | 26 | **PLEX_URL** - Set this to the local URL for your Plex Server 27 | 28 | **PLEX_TOKEN** - Find your token by following the instructions here https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ 29 | 30 | **TRAKT_CLIENT_ID** - You can create a Trakt account, then click *Your API Apps* under Settings, create a new app, give it a name. You can leave image, javascript, and the check-in/scrobble permissions blank, but you need to select a redirect uri. You can use the device authentication option listed in the help text or set something like google.com 31 | 32 | ## Command Line Interface 33 | 34 | The script is constructed by calling `python trakt_seasons.py [general parameters] reset|pull [command-specific parameters]` 35 | 36 | ### General Parameters 37 | 38 | **`--help|-h`**: Show a help message for general parameters and commands and exit 39 | 40 | **`--debug`**: Prints debug info to the log file. Default is false 41 | 42 | ### Command 43 | One of `reset` or `pull` is required. 44 | * **`reset`**: reset the specified season data in Plex. 45 | * **`pull`**: pull the specified season data from Trakt into Plex. 46 | 47 | ### Command Specific Parameters 48 | 49 | **`--help|-h`**: Show a help message for command-specific parameters and exit 50 | 51 | One of the below two parameters is required to determine which data to process. Both can work together, e.g. if you want your entire anime library but only "American Horror Story" from your TV Show library. 52 | 53 | * **`--libraries`**: A list of libraries to process. Do not use if you want to select specific shows instead. Please note that you need to use the exact name of the library as it appears in Plex, place it in quotes, and separate multiple libraries with a space after using this flag.\ 54 | Example: `--libraries "TV Shows" "Anime"` 55 | * **`--shows`**: A list of specific shows to process. Do not use if you want to select entire libraries instead. Please note that you need to use the exact name of the show as it appears in Plex, place it in quotes, and separate multiple libraries with a comma.\ 56 | Example: `--shows "Avatar: The Last Airbender" "The Legend of Korra"` 57 | 58 | **`--data|-d`**: Specify whether to proccess `title` or `summary` data. Default is both.\ 59 | Example: `--data title` 60 | 61 | **`--exclude|-e`**: Specify a label (in lowercase) that you want to exclude. Shows with this label will not be processed.\ 62 | Example: `--exclude overriden` 63 | 64 | **`--unlock|-u`**: 65 | * (*reset*) Specify whether to unlock `title` or `summary` data after resetting so it can be rescraped in subsequent pulls. Default is none so that all processed items are locked after the reset. To unlock both, add both values after the flag.\ 66 | Example: `--unlock title summary` 67 | * (*pull*) Specify whether to unlock `successful_title`, `failed_title`, `successful_summary`, or `failed_summary` after the pull so that those items can be rescraped in subsequent pulls. Default is none so that all processed items are locked after the pull. To unlock multiple items, add those values after the flag.\ 68 | Example: `--unlock failed_title failed_summary` 69 | 70 | **`--force|-f`**: (*pull only*) Set this flag to force rescrape all existing locked season title/summary data in Plex. Default is False so that the script ignores and filters out these items. 71 | 72 | ### Full Example 73 | `python trakt_seasons.py --debug pull --libraries "TV Shows" --shows "Dragon Ball Z" -f -d summary -u failed_summary` 74 | 75 | You can also run this on two separate schedules. One will scrape only new items, and another will force scrape all items, excluding labels you can use to signal that certain things have been overriden manually 76 | 77 | `python trakt_seasons.py --debug pull --libraries "TV Shows" "Anime"` running weekly\ 78 | `python trakt_seasons.py --debug pull --force --exclude plextrakt --libraries "TV Shows" "Anime"` running every month or two 79 | -------------------------------------------------------------------------------- /trakt_seasons.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Description: Rename season title for TV shows on Plex and populate season data such as titles and summaries. 5 | # Make sure plexapi is up to date as this script utilizes features available in the latest version only 6 | # Based on the script by SwiftPanda https://github.com/blacktwin/JBOPS/blob/master/utility/rename_seasons.py 7 | # Author: /u/RG9400 8 | # Requires: plexapi, requests 9 | 10 | import re 11 | import os 12 | import sys 13 | import time 14 | import requests 15 | from logging.handlers import RotatingFileHandler 16 | from logging import DEBUG, INFO, getLogger, Formatter, StreamHandler 17 | from plexapi.server import PlexServer 18 | import argparse 19 | 20 | ################################ CONFIG BELOW ################################ 21 | PLEX_URL = "http://localhost:32400" 22 | PLEX_TOKEN = "" 23 | TRAKT_CLIENT_ID = "" 24 | ############################################################################### 25 | 26 | ## CODE BELOW ## 27 | 28 | #Set up the argparse for all the script arguments 29 | parser = argparse.ArgumentParser(description="Plextrakt Season Integration", epilog='Run \'trakt_seasons.py COMMAND --help\' for more info on a command') 30 | parser._positionals.title = 'required arguments' 31 | parser.add_argument( 32 | '--debug', 33 | action='store_true', 34 | default=False, 35 | help='Print debug info to the log file. Default is False' 36 | ) 37 | parent_parser = argparse.ArgumentParser(add_help=False) 38 | items = parent_parser.add_argument_group("(required) items to process") 39 | items.add_argument( 40 | '--libraries', 41 | nargs='+', 42 | default=[], 43 | help='Space separated list of libraries in quotes to process with names matching Plex exactly.' 44 | ) 45 | items.add_argument( 46 | '--shows', 47 | nargs='+', 48 | default=[], 49 | help='Space separated list of shows in quotes to process with names matching Plex exactly.' 50 | ) 51 | parent_parser.add_argument( 52 | '--data', '-d', 53 | choices=['title', 'summary'], 54 | nargs='+', 55 | default=['title', 'summary'], 56 | help='Process title or summary data. Default is both' 57 | ) 58 | parent_parser.add_argument( 59 | '--exclude', '-e', 60 | default=None, 61 | help='Labels to exclude from processing' 62 | ) 63 | subparsers = parser.add_subparsers(dest='command', help='COMMAND') 64 | reset = subparsers.add_parser( 65 | 'reset', 66 | help='Reset season data in Plex', 67 | parents=[parent_parser]) 68 | reset.add_argument( 69 | '--unlock', '-u', 70 | choices=['title', 'summary'], 71 | nargs='+', 72 | default=[], 73 | help='Space separated list of fields to unlock after reset so they can be rescraped. Default is none' 74 | ) 75 | pull = subparsers.add_parser( 76 | 'pull', 77 | help='Pull season data from Trakt into Plex', 78 | parents=[parent_parser] 79 | ) 80 | pull.add_argument( 81 | '--force', '-f', 82 | action='store_true', 83 | default=False, 84 | help="Rescrape existing locked data" 85 | ) 86 | pull.add_argument( 87 | '--unlock', '-u', 88 | choices=['failed_title', 'failed_summary', 'successful_title', 'successful summary'], 89 | nargs='+', 90 | default=[], 91 | help='Unlock failed_title, successful_title, failed_summary, or successful_summary after reset so they can be rescraped. Default is none' 92 | ) 93 | 94 | args = parser.parse_args() 95 | if not (args.shows or args.libraries): 96 | parser.error('Nothing to process, add at least one item via either --shows or --libraries') 97 | 98 | # Set up the rotating log files 99 | size = 10*1024*1024 # 5MB 100 | max_files = 5 # Keep up to 5 logs 101 | log_path = os.environ.get('LOG_FOLDER', os.path.dirname(sys.argv[0])) 102 | log_filename = os.path.join(log_path, 'trakt_seasons.log') 103 | file_logger = RotatingFileHandler(log_filename, maxBytes=size, backupCount=max_files) 104 | console = StreamHandler() 105 | logger_formatter = Formatter('[%(asctime)s] %(name)s - %(levelname)s - %(message)s') 106 | console_formatter = Formatter('%(message)s') 107 | console.setFormatter(console_formatter) 108 | file_logger.setFormatter(logger_formatter) 109 | log = getLogger('Trakt Seasons') 110 | console.setLevel(INFO) 111 | if args.debug: 112 | file_logger.setLevel(DEBUG) 113 | log.setLevel(DEBUG) 114 | else: 115 | file_logger.setLevel(INFO) 116 | log.setLevel(INFO) 117 | log.addHandler(console) 118 | log.addHandler(file_logger) 119 | 120 | def main(): 121 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 122 | trakt_headers = {"content-type": "application/json", "trakt-api-version": "2", "trakt-api-key": TRAKT_CLIENT_ID, 'User-agent': 'Season Renamer v0.1'} 123 | 124 | process_list = [] 125 | if len(args.libraries) > 0: 126 | for library in args.libraries: 127 | try: 128 | section = plex.library.section(library) 129 | log.info("Processing library {}".format(library)) 130 | for show in section.all(): 131 | if args.exclude not in (label.tag.lower() for label in show.labels): 132 | process_list.append(show) 133 | else: 134 | log.info("Excluding {} because {} found in show's labels".format(show.title, args.exclude)) 135 | continue 136 | except: 137 | log.error("Could not find library {} in Plex".format(library)) 138 | else: 139 | log.debug('No libraries identified to be processed') 140 | if len(args.shows) > 0: 141 | for show in args.shows: 142 | try: 143 | entry = plex.search(show)[0] 144 | if args.exclude not in (label.tag.lower() for label in entry.labels): 145 | process_list.append(entry) 146 | log.info("Proccessing show {}".format(show)) 147 | else: 148 | log.info("Excluding {} because {} found in show's labels".format(show, args.exclude)) 149 | continue 150 | except: 151 | log.error("Could not find show {} in Plex".format(show)) 152 | else: 153 | log.debug('No specific shows identified to be processed') 154 | deduped_list = set(process_list) 155 | 156 | if args.command == 'reset': 157 | reset_show_counter = 0 158 | reset_season_counter = 0 159 | log.info('Begining reset process') 160 | reset_title_locking_indicator = int('title' not in args.unlock) 161 | reset_summary_locking_indicator = int('summary' not in args.unlock) 162 | for show in deduped_list: 163 | reset_show_counter += 1 164 | for season in show.seasons(): 165 | reset_season_counter += 1 166 | old_season_title = season.title 167 | old_season_summary = season.summary 168 | edit = {} 169 | if 'title' in args.data: 170 | edit['title.value'] = '' 171 | edit['title.locked'] = reset_title_locking_indicator 172 | if 'summary' in args.data: 173 | edit['summary.value'] = '' 174 | edit['summary.locked'] = reset_summary_locking_indicator 175 | season.edit(**edit) 176 | if 'title' in args.data: 177 | log.debug(""" 178 | Show: {} 179 | Season: {} 180 | Old Title: {} 181 | New Title: {} 182 | Locked: {} 183 | """ 184 | .format(show.title, season.seasonNumber, old_season_title, season.reload().title, bool(reset_title_locking_indicator))) 185 | if 'summaries' in args.data: 186 | log.debug(""" 187 | Show: {} 188 | Season: {} 189 | Old Summary: {} 190 | New Summary: {} 191 | Locked: {} 192 | """ 193 | .format(show.title, season.seasonNumber, old_season_summary, season.reload().summary, bool(reset_summary_locking_indicator))) 194 | log.info("Reset process finished") 195 | log.info("Reset {} seasons across {} shows".format(reset_season_counter, reset_show_counter)) 196 | 197 | if args.command == 'pull': 198 | log.info('Starting {} process'.format(args.command)) 199 | show_counter = 0 200 | season_counter = 0 201 | new_title_counter = 0 202 | new_summary_counter = 0 203 | pull_successful_title_locking_indicator = int('successful_title' not in args.unlock) 204 | pull_failed_title_locking_indicator = int('failed_title' not in args.unlock) 205 | pull_succesful_summary_locking_indicator = int('successful_summary' not in args.unlock) 206 | pull_failed_summary_locking_indicator = int('failed_summary' not in args.unlock) 207 | if args.force: 208 | log.info("Force Refresh enabled. Rescraping all items in the list") 209 | else: 210 | log.info("Force refresh disabled. Will filter out locked items") 211 | for show in deduped_list: 212 | season_list = list(filter(lambda x: (x.seasonNumber != 0), show.seasons())) 213 | if not args.force: 214 | if 'title' in args.data: 215 | all_titles_locked = all("title" in (field.name for field in season.fields) for season in season_list) 216 | else: 217 | all_titles_locked = True 218 | if 'summary' in args.data: 219 | all_summaries_locked = all("summary" in (field.name for field in season.fields) for season in season_list) 220 | else: 221 | all_summaries_locked = True 222 | if all_titles_locked and all_summaries_locked: 223 | log.debug('Skipping show {} because force refresh is disabled and all requested items are locked'.format(show.title)) 224 | continue 225 | if show.guids: 226 | imdb = next((guid.id for guid in show.guids if "imdb" in guid.id), None) 227 | tmdb = next((guid.id for guid in show.guids if "tmdb" in guid.id), None) 228 | tvdb = next((guid.id for guid in show.guids if "tvdb" in guid.id), None) 229 | elif "thetvdb" in show.guid: 230 | tvdb = show.guid 231 | elif "themoviedb" in show.guid: 232 | tmdb = show.guid 233 | else: 234 | log.warning('Could not find any external identifier for show {}'.format(show.title)) 235 | continue 236 | if imdb: 237 | id = imdb 238 | type = 'imdb' 239 | elif tmdb: 240 | id = tmdb 241 | type = 'tmdb' 242 | elif tvdb: 243 | id = tvdb 244 | type = 'tvdb' 245 | id = re.search(r':\/\/([^\/]*)', id).group(1) 246 | log.debug('Using ID Type {} with identifier {}'.format(type, id)) 247 | 248 | if type == 'imdb': 249 | slug = id 250 | else: 251 | trakt_search_api = 'https://api.trakt.tv/search/{}/{}?type=show'.format(type, id) 252 | try: 253 | trakt_search = requests.get(trakt_search_api, headers=trakt_headers).json() 254 | slug = trakt_search[0]['show']['ids']['slug'] 255 | except: 256 | log.warning('Could not find Trakt slug for show {} and guid {}'.format(show.title, show.guid)) 257 | continue 258 | 259 | trakt_season_api = 'https://api.trakt.tv/shows/{}/seasons?extended=full'.format(slug) 260 | try: 261 | trakt_seasons = requests.get(trakt_season_api, headers=trakt_headers).json() 262 | except: 263 | log.warning("Trakt season page inaccessible, skipping show {}".format(show.title)) 264 | continue 265 | show_counter += 1 266 | for season in trakt_seasons: 267 | season_number = season['number'] 268 | if season_number != 0: 269 | season_title = season['title'] 270 | if season_title == 'Season {}'.format(season_number): 271 | season_title = None 272 | season_summary = season['overview'] 273 | try: 274 | plex_season = show.season(season_number) 275 | if not args.force: 276 | locked_title = "title" in (field.name for field in plex_season.fields) 277 | locked_summary = "summary" in (field.name for field in plex_season.fields) 278 | else: 279 | locked_title = False 280 | locked_summary = False 281 | if not (locked_title and locked_summary): 282 | season_counter += 1 283 | old_season_title = plex_season.title 284 | old_season_summary = plex_season.summary 285 | edit = {} 286 | if 'title' in args.data and not locked_title: 287 | if season_title: 288 | edit['title.value'] = season_title 289 | edit['title.locked'] = pull_successful_title_locking_indicator 290 | log.debug(""" 291 | Show: {} 292 | Season: {} 293 | Old Title: {} 294 | New Title: {} 295 | Locked: {} 296 | """ 297 | .format(show.title, plex_season.seasonNumber, old_season_title, season_title, bool(pull_successful_title_locking_indicator))) 298 | new_title_counter += 1 299 | else: 300 | edit['title.value'] = old_season_title 301 | edit['title.locked'] = pull_failed_title_locking_indicator 302 | log.debug("{} Season {} - No title found on Trakt. Locked on Plex: {}". format(show.title, plex_season.seasonNumber, bool(pull_failed_title_locking_indicator))) 303 | if 'summary' in args.data and not locked_summary: 304 | if season_summary: 305 | edit['summary.value'] = season_summary 306 | edit['summary.locked'] = pull_succesful_summary_locking_indicator 307 | log.debug(""" 308 | Show: {} 309 | Season: {} 310 | Old Summary: {} 311 | New Summary: {} 312 | Locked: {} 313 | """ 314 | .format(show.title, plex_season.seasonNumber, old_season_summary, season_summary, bool(pull_succesful_summary_locking_indicator))) 315 | new_summary_counter += 1 316 | else: 317 | edit['summary.value'] = old_season_summary 318 | edit['summary.locked'] = pull_failed_summary_locking_indicator 319 | log.debug("{} Season {} - No summary found on Trakt. Locked on Plex: {}".format(show.title, plex_season.seasonNumber, bool(pull_failed_summary_locking_indicator))) 320 | plex_season.edit(**edit) 321 | except: 322 | log.debug("{} Season {} exists on Trakt but not in Plex".format(show.title, season_number)) 323 | time.sleep(1) 324 | log.info("Pull process finished") 325 | log.info("Processed {} shows across {} seasons. Found {} new titles and {} new summaries".format(show_counter, season_counter, new_title_counter, new_summary_counter)) 326 | 327 | if __name__ == "__main__": 328 | main() 329 | print("Done.") 330 | --------------------------------------------------------------------------------