diff --git a/README.md b/README.md index 0d02f4b15..5608bfbfc 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # Plex Meta Manager -#### Version 1.3.0 +#### Version 1.4.0 The original concept for Plex Meta Manager is [Plex Auto Collections](https://github.com/mza921/Plex-Auto-Collections), but this is rewritten from the ground up to be able to include a scheduler, metadata edits, multiple libraries, and logging. Plex Meta Manager is a Python 3 script that can be continuously run using YAML configuration files to update on a schedule the metadata of the movies, shows, and collections in your libraries as well as automatically build collections based on various methods all detailed in the wiki. Some collection examples that the script can automatically build and update daily include Plex Based Searches like actor, genre, or studio collections or Collections based on TMDb, IMDb, Trakt, TVDb, AniDB, or MyAnimeList lists and various other services. The script can update many metadata fields for movies, shows, collections, seasons, and episodes and can act as a backup if your plex DB goes down. It can even update metadata the plex UI can't like Season Names. If the time is put into the metadata configuration file you can have a way to recreate your library and all its metadata changes with the click of a button. -The script is designed to work with most Metadata agents including the new Plex Movie Agent, [Hama Anime Agent](https://github.com/ZeroQI/Hama.bundle), and [MyAnimeList Anime Agent](https://github.com/Fribb/MyAnimeList.bundle). +The script is designed to work with most Metadata agents including the new Plex Movie Agent, New Plex TV Agent, [Hama Anime Agent](https://github.com/ZeroQI/Hama.bundle), and [MyAnimeList Anime Agent](https://github.com/Fribb/MyAnimeList.bundle). + +[![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/donate?business=JTK3CVKF3ZHP2&item_name=Plex+Meta+Manager¤cy_code=USD) ## Getting Started @@ -20,4 +22,3 @@ The script is designed to work with most Metadata agents including the new Plex * To see user submitted Metadata configuration files and you could even add your own go to the [Plex Meta Manager Configs](https://github.com/meisnate12/Plex-Meta-Manager-Configs) * Pull Request are welcome but please submit them to the develop branch * If you wish to contribute to the Wiki please fork and send a pull request on the [Plex Meta Manager Wiki Repository](https://github.com/meisnate12/Plex-Meta-Manager-Wiki) -* [Buy Me a Pizza](https://www.buymeacoffee.com/meisnate12) diff --git a/config/config.yml.template b/config/config.yml.template index bfec47e66..74bfefa29 100644 --- a/config/config.yml.template +++ b/config/config.yml.template @@ -43,6 +43,8 @@ sonarr: # Can be individually specified root_folder_path: "S:/TV Shows" add: false search: false +omdb: + apikey: ######## trakt: client_id: ################################################################ client_secret: ################################################################ diff --git a/modules/anidb.py b/modules/anidb.py index 5597b0e92..b6138fae9 100644 --- a/modules/anidb.py +++ b/modules/anidb.py @@ -7,10 +7,8 @@ logger = logging.getLogger("Plex Meta Manager") class AniDBAPI: - def __init__(self, Cache=None, TMDb=None, Trakt=None): - self.Cache = Cache - self.TMDb = TMDb - self.Trakt = Trakt + def __init__(self, config): + self.config = config self.urls = { "anime": "https://anidb.net/anime", "popular": "https://anidb.net/latest/anime/popular/?h=1", @@ -80,9 +78,10 @@ def get_items(self, method, data, language, status_message=True): movie_ids = [] for anidb_id in anime_ids: try: - tmdb_id = self.convert_from_imdb(self.convert_anidb_to_imdb(anidb_id)) - if tmdb_id: movie_ids.append(tmdb_id) - else: raise Failed + for imdb_id in self.convert_anidb_to_imdb(anidb_id): + tmdb_id, _ = self.config.convert_from_imdb(imdb_id, language) + if tmdb_id: movie_ids.append(tmdb_id) + else: raise Failed except Failed: try: show_ids.append(self.convert_anidb_to_tvdb(anidb_id)) except Failed: logger.error(f"AniDB Error: No TVDb ID or IMDb ID found for AniDB ID: {anidb_id}") @@ -91,36 +90,3 @@ def get_items(self, method, data, language, status_message=True): logger.debug(f"TMDb IDs Found: {movie_ids}") logger.debug(f"TVDb IDs Found: {show_ids}") return movie_ids, show_ids - - def convert_from_imdb(self, imdb_id): - output_tmdb_ids = [] - if not isinstance(imdb_id, list): - imdb_id = [imdb_id] - - for imdb in imdb_id: - expired = False - if self.Cache: - tmdb_id, tvdb_id = self.Cache.get_ids_from_imdb(imdb) - if not tmdb_id: - tmdb_id, expired = self.Cache.get_tmdb_from_imdb(imdb) - if expired: - tmdb_id = None - else: - tmdb_id = None - from_cache = tmdb_id is not None - - if not tmdb_id and self.TMDb: - try: tmdb_id = self.TMDb.convert_imdb_to_tmdb(imdb) - except Failed: pass - if not tmdb_id and self.Trakt: - try: tmdb_id = self.Trakt.convert_imdb_to_tmdb(imdb) - except Failed: pass - try: - if tmdb_id and not from_cache: self.TMDb.get_movie(tmdb_id) - except Failed: tmdb_id = None - if tmdb_id: output_tmdb_ids.append(tmdb_id) - if self.Cache and tmdb_id and expired is not False: - self.Cache.update_imdb("movie", expired, imdb, tmdb_id) - if len(output_tmdb_ids) == 0: raise Failed(f"AniDB Error: No TMDb ID found for IMDb: {imdb_id}") - elif len(output_tmdb_ids) == 1: return output_tmdb_ids[0] - else: return output_tmdb_ids diff --git a/modules/builder.py b/modules/builder.py index e4082b853..b1061add8 100644 --- a/modules/builder.py +++ b/modules/builder.py @@ -240,6 +240,14 @@ def replace_txt(txt): self.summaries[method_name] = config.TMDb.get_list(util.regex_first_int(data[m], "TMDb List ID")).description elif method_name == "tmdb_biography": self.summaries[method_name] = config.TMDb.get_person(util.regex_first_int(data[m], "TMDb Person ID")).biography + elif method_name == "tvdb_summary": + self.summaries[method_name] = config.TVDb.get_movie_or_show(data[m], self.library.Plex.language, self.library.is_movie).summary + elif method_name == "tvdb_description": + self.summaries[method_name] = config.TVDb.get_list_description(data[m], self.library.Plex.language) + elif method_name == "trakt_description": + self.summaries[method_name] = config.Trakt.standard_list(config.Trakt.validate_trakt_list(util.get_list(data[m]))[0]).description + elif method_name == "letterboxd_description": + self.summaries[method_name] = config.Letterboxd.get_list_description(data[m], self.library.Plex.language) elif method_name == "collection_mode": if data[m] in ["default", "hide", "hide_items", "show_items", "hideItems", "showItems"]: if data[m] == "hide_items": self.details[method_name] = "hideItems" @@ -258,6 +266,8 @@ def replace_txt(txt): self.posters[method_name] = f"{config.TMDb.image_url}{config.TMDb.get_movie_show_or_collection(util.regex_first_int(data[m], 'TMDb ID'), self.library.is_movie).poster_path}" elif method_name == "tmdb_profile": self.posters[method_name] = f"{config.TMDb.image_url}{config.TMDb.get_person(util.regex_first_int(data[m], 'TMDb Person ID')).profile_path}" + elif method_name == "tvdb_poster": + self.posters[method_name] = f"{config.TVDb.get_movie_or_series(data[m], self.library.Plex.language, self.library.is_movie).poster_path}" elif method_name == "file_poster": if os.path.exists(data[m]): self.posters[method_name] = os.path.abspath(data[m]) else: raise Failed(f"Collection Error: Poster Path Does Not Exist: {os.path.abspath(data[m])}") @@ -265,6 +275,8 @@ def replace_txt(txt): self.backgrounds[method_name] = data[m] elif method_name == "tmdb_background": self.backgrounds[method_name] = f"{config.TMDb.image_url}{config.TMDb.get_movie_show_or_collection(util.regex_first_int(data[m], 'TMDb ID'), self.library.is_movie).poster_path}" + elif method_name == "tvdb_background": + self.posters[method_name] = f"{config.TVDb.get_movie_or_series(data[m], self.library.Plex.language, self.library.is_movie).background_path}" elif method_name == "file_background": if os.path.exists(data[m]): self.backgrounds[method_name] = os.path.abspath(data[m]) else: raise Failed(f"Collection Error: Background Path Does Not Exist: {os.path.abspath(data[m])}") @@ -294,6 +306,8 @@ def replace_txt(txt): else: final_values.append(value) self.methods.append(("plex_search", [[(method_name, final_values)]])) + elif method_name == "title": + self.methods.append(("plex_search", [[(method_name, data[m])]])) elif method_name in util.plex_searches: self.methods.append(("plex_search", [[(method_name, util.get_list(data[m]))]])) elif method_name == "plex_all": @@ -313,6 +327,12 @@ def replace_txt(txt): self.methods.append((method_name, config.AniDB.validate_anidb_list(util.get_int_list(data[m], "AniDB ID"), self.library.Plex.language))) elif method_name == "trakt_list": self.methods.append((method_name, config.Trakt.validate_trakt_list(util.get_list(data[m])))) + elif method_name == "trakt_list_details": + valid_list = config.Trakt.validate_trakt_list(util.get_list(data[m])) + item = config.Trakt.standard_list(valid_list[0]) + if hasattr(item, "description") and item.description: + self.summaries[method_name] = item.description + self.methods.append((method_name[:-8], valid_list)) elif method_name == "trakt_watchlist": self.methods.append((method_name, config.Trakt.validate_trakt_watchlist(util.get_list(data[m]), self.library.is_movie))) elif method_name == "imdb_list": @@ -327,6 +347,12 @@ def replace_txt(txt): list_count = 0 new_list.append({"url": imdb_url, "limit": list_count}) self.methods.append((method_name, new_list)) + elif method_name == "letterboxd_list": + self.methods.append((method_name, util.get_list(data[m], split=False))) + elif method_name == "letterboxd_list_details": + values = util.get_list(data[m], split=False) + self.summaries[method_name] = config.Letterboxd.get_list_description(values[0], self.library.Plex.language) + self.methods.append((method_name[:-8], values)) elif method_name in util.dictionary_lists: if isinstance(data[m], dict): def get_int(parent, method, data_in, default_in, minimum=1, maximum=None): @@ -402,6 +428,9 @@ def get_int(parent, method, data_in, default_in, minimum=1, maximum=None): if len(years) > 0: used.append(util.remove_not(search)) searches.append((search, util.get_int_list(data[m][s], util.remove_not(search)))) + elif search == "title": + used.append(util.remove_not(search)) + searches.append((search, data[m][s])) elif search in util.plex_searches: used.append(util.remove_not(search)) searches.append((search, util.get_list(data[m][s]))) @@ -521,6 +550,30 @@ def get_int(parent, method, data_in, default_in, minimum=1, maximum=None): logger.warning(f"Collection Warning: {method_name} must be an integer greater then 0 defaulting to 20") list_count = 20 self.methods.append((method_name, [list_count])) + elif "tvdb" in method_name: + values = util.get_list(data[m]) + if method_name[-8:] == "_details": + if method_name == "tvdb_movie_details": + item = config.TVDb.get_movie(self.library.Plex.language, values[0]) + if hasattr(item, "description") and item.description: + self.summaries[method_name] = item.description + if hasattr(item, "background_path") and item.background_path: + self.backgrounds[method_name] = f"{config.TMDb.image_url}{item.background_path}" + if hasattr(item, "poster_path") and item.poster_path: + self.posters[method_name] = f"{config.TMDb.image_url}{item.poster_path}" + elif method_name == "tvdb_show_details": + item = config.TVDb.get_series(self.library.Plex.language, values[0]) + if hasattr(item, "description") and item.description: + self.summaries[method_name] = item.description + if hasattr(item, "background_path") and item.background_path: + self.backgrounds[method_name] = f"{config.TMDb.image_url}{item.background_path}" + if hasattr(item, "poster_path") and item.poster_path: + self.posters[method_name] = f"{config.TMDb.image_url}{item.poster_path}" + elif method_name == "tvdb_list_details": + self.summaries[method_name] = config.TVDb.get_list_description(values[0], self.library.Plex.language) + self.methods.append((method_name[:-8], values)) + else: + self.methods.append((method_name, values)) elif method_name in util.tmdb_lists: values = config.TMDb.validate_tmdb_list(util.get_int_list(data[m], f"TMDb {util.tmdb_type[method_name]} ID"), util.tmdb_type[method_name]) if method_name[-8:] == "_details": @@ -549,8 +602,10 @@ def get_int(parent, method, data_in, default_in, minimum=1, maximum=None): self.methods.append((method_name, util.get_list(data[m]))) elif method_name not in util.other_attributes: raise Failed(f"Collection Error: {method_name} attribute not supported") - else: + elif m in util.all_lists or m in util.method_alias or m in util.plex_searches: raise Failed(f"Collection Error: {m} attribute is blank") + else: + logger.warning(f"Collection Warning: {m} attribute is blank") self.sync = self.library.sync_mode == "sync" if "sync_mode" in data: @@ -600,18 +655,33 @@ def check_map(input_ids): items_found += len(items) elif method == "plex_search": search_terms = {} - for i, attr_pair in enumerate(value): - search_list = attr_pair[1] - final_method = attr_pair[0][:-4] + "!" if attr_pair[0][-4:] == ".not" else attr_pair[0] - if self.library.is_show: - final_method = "show." + final_method - search_terms[final_method] = search_list - ors = "" - for o, param in enumerate(attr_pair[1]): - or_des = " OR " if o > 0 else f"{attr_pair[0]}(" - ors += f"{or_des}{param}" - logger.info(f"\t\t AND {ors})" if i > 0 else f"Processing {pretty}: {ors})") - items = self.library.Plex.search(**search_terms) + title_search = None + has_processed = False + for search_method, search_data in value: + if search_method == "title": + title_search = search_data + logger.info(f"Processing {pretty}: title({title_search})") + has_processed = True + + for search_method, search_list in value: + if search_method != "title": + final_method = search_method[:-4] + "!" if search_method[-4:] == ".not" else search_method + if self.library.is_show: + final_method = "show." + final_method + search_terms[final_method] = search_list + ors = "" + for o, param in enumerate(search_list): + or_des = " OR " if o > 0 else f"{search_method}(" + ors += f"{or_des}{param}" + if title_search or has_processed: + logger.info(f"\t\t AND {ors})") + else: + logger.info(f"Processing {pretty}: {ors})") + has_processed = True + if title_search: + items = self.library.Plex.search(title_search, **search_terms) + else: + items = self.library.Plex.search(**search_terms) items_found += len(items) elif method == "plex_collectionless": good_collections = [] @@ -648,6 +718,7 @@ def check_map(input_ids): elif "mal" in method: items_found += check_map(self.config.MyAnimeList.get_items(method, value)) elif "tvdb" in method: items_found += check_map(self.config.TVDb.get_items(method, value, self.library.Plex.language)) elif "imdb" in method: items_found += check_map(self.config.IMDb.get_items(method, value, self.library.Plex.language)) + elif "letterboxd" in method: items_found += check_map(self.config.Letterboxd.get_items(method, value, self.library.Plex.language)) elif "tmdb" in method: items_found += check_map(self.config.TMDb.get_items(method, value, self.library.is_movie)) elif "trakt" in method: items_found += check_map(self.config.Trakt.get_items(method, value, self.library.is_movie)) else: logger.error(f"Collection Error: {method} method not supported") @@ -694,7 +765,7 @@ def check_map(input_ids): missing_shows_with_names = [] for missing_id in missing_shows: try: - title = str(self.config.TVDb.get_series(self.library.Plex.language, tvdb_id=missing_id).title.encode("ascii", "replace").decode()) + title = str(self.config.TVDb.get_series(self.library.Plex.language, missing_id).title.encode("ascii", "replace").decode()) except Failed as e: logger.error(e) continue @@ -738,10 +809,13 @@ def get_summary(summary_method, summaries): return summaries[summary_method] if "summary" in self.summaries: summary = get_summary("summary", self.summaries) elif "tmdb_description" in self.summaries: summary = get_summary("tmdb_description", self.summaries) + elif "letterboxd_description" in self.summaries: summary = get_summary("letterboxd_description", self.summaries) elif "tmdb_summary" in self.summaries: summary = get_summary("tmdb_summary", self.summaries) + elif "tvdb_summary" in self.summaries: summary = get_summary("tvdb_summary", self.summaries) elif "tmdb_biography" in self.summaries: summary = get_summary("tmdb_biography", self.summaries) elif "tmdb_person" in self.summaries: summary = get_summary("tmdb_person", self.summaries) elif "tmdb_collection_details" in self.summaries: summary = get_summary("tmdb_collection_details", self.summaries) + elif "trakt_list_details" in self.summaries: summary = get_summary("trakt_list_details", self.summaries) elif "tmdb_list_details" in self.summaries: summary = get_summary("tmdb_list_details", self.summaries) elif "tmdb_actor_details" in self.summaries: summary = get_summary("tmdb_actor_details", self.summaries) elif "tmdb_crew_details" in self.summaries: summary = get_summary("tmdb_crew_details", self.summaries) @@ -749,6 +823,8 @@ def get_summary(summary_method, summaries): elif "tmdb_producer_details" in self.summaries: summary = get_summary("tmdb_producer_details", self.summaries) elif "tmdb_writer_details" in self.summaries: summary = get_summary("tmdb_writer_details", self.summaries) elif "tmdb_movie_details" in self.summaries: summary = get_summary("tmdb_movie_details", self.summaries) + elif "tvdb_movie_details" in self.summaries: summary = get_summary("tvdb_movie_details", self.summaries) + elif "tvdb_show_details" in self.summaries: summary = get_summary("tvdb_show_details", self.summaries) elif "tmdb_show_details" in self.summaries: summary = get_summary("tmdb_show_details", self.summaries) else: summary = None if summary: @@ -810,7 +886,7 @@ def get_summary(summary_method, summaries): dirs = [folder for folder in os.listdir(path) if os.path.isdir(os.path.join(path, folder))] if len(dirs) > 0: for item in collection.items(): - folder = os.path.basename(os.path.dirname(item.locations[0])) + folder = os.path.basename(os.path.dirname(item.locations[0]) if self.library.is_movie else item.locations[0]) if folder in dirs: matches = glob.glob(os.path.join(path, folder, "poster.*")) poster_path = os.path.abspath(matches[0]) if len(matches) > 0 else None @@ -824,6 +900,13 @@ def get_summary(summary_method, summaries): logger.info(f"Detail: asset_directory updated {item.title}'s background to [file] {background_path}") if poster_path is None and background_path is None: logger.warning(f"No Files Found: {os.path.join(path, folder)}") + if self.library.is_show: + for season in item.seasons(): + matches = glob.glob(os.path.join(path, folder, f"Season{'0' if season.seasonNumber < 10 else ''}{season.seasonNumber}.*")) + if len(matches) > 0: + season_path = os.path.abspath(matches[0]) + season.uploadPoster(filepath=season_path) + logger.info(f"Detail: asset_directory updated {item.title} Season {season.seasonNumber}'s poster to [file] {season_path}") else: logger.warning(f"No Folder: {os.path.join(path, folder)}") @@ -847,16 +930,19 @@ def set_image(image_method, images, is_background=False): elif "file_poster" in self.posters: set_image("file_poster", self.posters) elif "tmdb_poster" in self.posters: set_image("tmdb_poster", self.posters) elif "tmdb_profile" in self.posters: set_image("tmdb_profile", self.posters) + elif "tvdb_poster" in self.posters: set_image("tvdb_poster", self.posters) elif "asset_directory" in self.posters: set_image("asset_directory", self.posters) elif "tmdb_person" in self.posters: set_image("tmdb_person", self.posters) - elif "tmdb_collection_details" in self.posters: set_image("tmdb_collection", self.posters) + elif "tmdb_collection_details" in self.posters: set_image("tmdb_collection_details", self.posters) elif "tmdb_actor_details" in self.posters: set_image("tmdb_actor_details", self.posters) elif "tmdb_crew_details" in self.posters: set_image("tmdb_crew_details", self.posters) elif "tmdb_director_details" in self.posters: set_image("tmdb_director_details", self.posters) elif "tmdb_producer_details" in self.posters: set_image("tmdb_producer_details", self.posters) elif "tmdb_writer_details" in self.posters: set_image("tmdb_writer_details", self.posters) - elif "tmdb_movie_details" in self.posters: set_image("tmdb_movie", self.posters) - elif "tmdb_show_details" in self.posters: set_image("tmdb_show", self.posters) + elif "tmdb_movie_details" in self.posters: set_image("tmdb_movie_details", self.posters) + elif "tvdb_movie_details" in self.posters: set_image("tvdb_movie_details", self.posters) + elif "tvdb_show_details" in self.posters: set_image("tvdb_show_details", self.posters) + elif "tmdb_show_details" in self.posters: set_image("tmdb_show_details", self.posters) else: logger.info("No poster to update") logger.info("") @@ -867,25 +953,28 @@ def set_image(image_method, images, is_background=False): logger.info(f"Method: {b} Background: {self.backgrounds[b]}") if "url_background" in self.backgrounds: set_image("url_background", self.backgrounds, is_background=True) - elif "file_background" in self.backgrounds: set_image("file_poster", self.backgrounds, is_background=True) - elif "tmdb_background" in self.backgrounds: set_image("tmdb_poster", self.backgrounds, is_background=True) + elif "file_background" in self.backgrounds: set_image("file_background", self.backgrounds, is_background=True) + elif "tmdb_background" in self.backgrounds: set_image("tmdb_background", self.backgrounds, is_background=True) + elif "tvdb_background" in self.backgrounds: set_image("tvdb_background", self.backgrounds, is_background=True) elif "asset_directory" in self.backgrounds: set_image("asset_directory", self.backgrounds, is_background=True) - elif "tmdb_collection_details" in self.backgrounds: set_image("tmdb_collection", self.backgrounds, is_background=True) - elif "tmdb_movie_details" in self.backgrounds: set_image("tmdb_movie", self.backgrounds, is_background=True) - elif "tmdb_show_details" in self.backgrounds: set_image("tmdb_show", self.backgrounds, is_background=True) + elif "tmdb_collection_details" in self.backgrounds: set_image("tmdb_collection_details", self.backgrounds, is_background=True) + elif "tmdb_movie_details" in self.backgrounds: set_image("tmdb_movie_details", self.backgrounds, is_background=True) + elif "tvdb_movie_details" in self.backgrounds: set_image("tvdb_movie_details", self.backgrounds, is_background=True) + elif "tvdb_show_details" in self.backgrounds: set_image("tvdb_show_details", self.backgrounds, is_background=True) + elif "tmdb_show_details" in self.backgrounds: set_image("tmdb_show_details", self.backgrounds, is_background=True) else: logger.info("No background to update") - def run_collections_again(self, library, collection_obj, movie_map, show_map): + def run_collections_again(self, collection_obj, movie_map, show_map): collection_items = collection_obj.items() if isinstance(collection_obj, Collections) else [] name = collection_obj.title if isinstance(collection_obj, Collections) else collection_obj rating_keys = [movie_map[mm] for mm in self.missing_movies if mm in movie_map] - if library.is_show: + if self.library.is_show: rating_keys.extend([show_map[sm] for sm in self.missing_shows if sm in show_map]) if len(rating_keys) > 0: for rating_key in rating_keys: try: - current = library.fetchItem(int(rating_key)) + current = self.library.fetchItem(int(rating_key)) except (BadRequest, NotFound): logger.error(f"Plex Error: Item {rating_key} not found") continue @@ -894,7 +983,7 @@ def run_collections_again(self, library, collection_obj, movie_map, show_map): else: current.addCollection(name) logger.info(f"{name} Collection | + | {current.title}") - logger.info(f"{len(rating_keys)} {'Movie' if library.is_movie else 'Show'}{'s' if len(rating_keys) > 1 else ''} Processed") + logger.info(f"{len(rating_keys)} {'Movie' if self.library.is_movie else 'Show'}{'s' if len(rating_keys) > 1 else ''} Processed") if len(self.missing_movies) > 0: logger.info("") @@ -910,12 +999,12 @@ def run_collections_again(self, library, collection_obj, movie_map, show_map): logger.info("") logger.info(f"{len(self.missing_movies)} Movie{'s' if len(self.missing_movies) > 1 else ''} Missing") - if len(self.missing_shows) > 0 and library.is_show: + if len(self.missing_shows) > 0 and self.library.is_show: logger.info("") for missing_id in self.missing_shows: if missing_id not in show_map: try: - title = str(self.config.TVDb.get_series(self.library.Plex.language, tvdb_id=missing_id).title.encode("ascii", "replace").decode()) + title = str(self.config.TVDb.get_series(self.library.Plex.language, missing_id).title.encode("ascii", "replace").decode()) except Failed as e: logger.error(e) continue diff --git a/modules/cache.py b/modules/cache.py index 2be4cd4f7..d11f08f38 100644 --- a/modules/cache.py +++ b/modules/cache.py @@ -1,6 +1,7 @@ import logging, os, random, sqlite3 from contextlib import closing from datetime import datetime, timedelta +from modules.util import Failed logger = logging.getLogger("Plex Meta Manager") @@ -13,28 +14,42 @@ def __init__(self, config_path, expiration): cursor.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='guids'") if cursor.fetchone()[0] == 0: logger.info(f"Initializing cache database at {cache}") - cursor.execute( - """CREATE TABLE IF NOT EXISTS guids ( - INTEGER PRIMARY KEY, - plex_guid TEXT, - tmdb_id TEXT, - imdb_id TEXT, - tvdb_id TEXT, - anidb_id TEXT, - mal_id TEXT, - expiration_date TEXT, - media_type TEXT)""" - ) - cursor.execute( - """CREATE TABLE IF NOT EXISTS imdb_map ( - INTEGER PRIMARY KEY, - imdb_id TEXT, - t_id TEXT, - expiration_date TEXT, - media_type TEXT)""" - ) else: logger.info(f"Using cache database at {cache}") + cursor.execute( + """CREATE TABLE IF NOT EXISTS guids ( + INTEGER PRIMARY KEY, + plex_guid TEXT UNIQUE, + tmdb_id TEXT, + imdb_id TEXT, + tvdb_id TEXT, + anidb_id TEXT, + mal_id TEXT, + expiration_date TEXT, + media_type TEXT)""" + ) + cursor.execute( + """CREATE TABLE IF NOT EXISTS imdb_map ( + INTEGER PRIMARY KEY, + imdb_id TEXT UNIQUE, + t_id TEXT, + expiration_date TEXT, + media_type TEXT)""" + ) + cursor.execute( + """CREATE TABLE IF NOT EXISTS omdb_data ( + INTEGER PRIMARY KEY, + imdb_id TEXT UNIQUE, + title TEXT, + year INTEGER, + content_rating TEXT, + genres TEXT, + imdb_rating REAL, + imdb_votes INTEGER, + metacritic_rating INTEGER, + type TEXT, + expiration_date TEXT)""" + ) self.expiration = expiration self.cache_path = cache @@ -82,6 +97,40 @@ def get_id(self, media_type, from_id, to_id, key): expired = time_between_insertion.days > self.expiration return id_to_return, expired + def get_ids(self, media_type, plex_guid=None, tmdb_id=None, imdb_id=None, tvdb_id=None): + ids_to_return = {} + expired = None + if plex_guid: + key = plex_guid + key_type = "plex_guid" + elif tmdb_id: + key = tmdb_id + key_type = "tmdb_id" + elif imdb_id: + key = imdb_id + key_type = "imdb_id" + elif tvdb_id: + key = tvdb_id + key_type = "tvdb_id" + else: + raise Failed("ID Required") + with sqlite3.connect(self.cache_path) as connection: + connection.row_factory = sqlite3.Row + with closing(connection.cursor()) as cursor: + cursor.execute(f"SELECT * FROM guids WHERE {key_type} = ? AND media_type = ?", (key, media_type)) + row = cursor.fetchone() + if row: + if row["plex_guid"]: ids_to_return["plex"] = row["plex_guid"] + if row["tmdb_id"]: ids_to_return["tmdb"] = int(row["tmdb_id"]) + if row["imdb_id"]: ids_to_return["imdb"] = row["imdb_id"] + if row["tvdb_id"]: ids_to_return["tvdb"] = int(row["tvdb_id"]) + if row["anidb_id"]: ids_to_return["anidb"] = int(row["anidb_id"]) + if row["mal_id"]: ids_to_return["mal"] = int(row["mal_id"]) + datetime_object = datetime.strptime(row["expiration_date"], "%Y-%m-%d") + time_between_insertion = datetime.now() - datetime_object + expired = time_between_insertion.days > self.expiration + return ids_to_return, expired + def update_guid(self, media_type, plex_guid, tmdb_id, imdb_id, tvdb_id, anidb_id, mal_id, expired): expiration_date = datetime.now() if expired is True else (datetime.now() - timedelta(days=random.randint(1, self.expiration))) with sqlite3.connect(self.cache_path) as connection: @@ -126,3 +175,35 @@ def update_imdb(self, media_type, expired, imdb_id, t_id): with closing(connection.cursor()) as cursor: cursor.execute("INSERT OR IGNORE INTO imdb_map(imdb_id) VALUES(?)", (imdb_id,)) cursor.execute("UPDATE imdb_map SET t_id = ?, expiration_date = ?, media_type = ? WHERE imdb_id = ?", (t_id, expiration_date.strftime("%Y-%m-%d"), media_type, imdb_id)) + + def query_omdb(self, imdb_id): + omdb_dict = {} + expired = None + with sqlite3.connect(self.cache_path) as connection: + connection.row_factory = sqlite3.Row + with closing(connection.cursor()) as cursor: + cursor.execute("SELECT * FROM omdb_data WHERE imdb_id = ?", (imdb_id,)) + row = cursor.fetchone() + if row: + omdb_dict["imdbID"] = row["imdb_id"] if row["imdb_id"] else None + omdb_dict["Title"] = row["title"] if row["title"] else None + omdb_dict["Year"] = row["year"] if row["year"] else None + omdb_dict["Rated"] = row["content_rating"] if row["content_rating"] else None + omdb_dict["Genre"] = row["genres"] if row["genres"] else None + omdb_dict["imdbRating"] = row["imdb_rating"] if row["imdb_rating"] else None + omdb_dict["imdbVotes"] = row["imdb_votes"] if row["imdb_votes"] else None + omdb_dict["Metascore"] = row["metacritic_rating"] if row["metacritic_rating"] else None + omdb_dict["Type"] = row["type"] if row["type"] else None + datetime_object = datetime.strptime(row["expiration_date"], "%Y-%m-%d") + time_between_insertion = datetime.now() - datetime_object + expired = time_between_insertion.days > self.expiration + return omdb_dict, expired + + def update_omdb(self, expired, omdb): + expiration_date = datetime.now() if expired is True else (datetime.now() - timedelta(days=random.randint(1, self.expiration))) + with sqlite3.connect(self.cache_path) as connection: + connection.row_factory = sqlite3.Row + with closing(connection.cursor()) as cursor: + cursor.execute("INSERT OR IGNORE INTO omdb_data(imdb_id) VALUES(?)", (omdb.imdb_id,)) + update_sql = "UPDATE omdb_data SET title = ?, year = ?, content_rating = ?, genres = ?, imdb_rating = ?, imdb_votes = ?, metacritic_rating = ?, type = ?, expiration_date = ? WHERE imdb_id = ?" + cursor.execute(update_sql, (omdb.title, omdb.year, omdb.content_rating, omdb.genres_str, omdb.imdb_rating, omdb.imdb_votes, omdb.metacritic_rating, omdb.type, expiration_date.strftime("%Y-%m-%d"), omdb.imdb_id)) diff --git a/modules/config.py b/modules/config.py index ccedad5d1..ad4389614 100644 --- a/modules/config.py +++ b/modules/config.py @@ -4,8 +4,10 @@ from modules.builder import CollectionBuilder from modules.cache import Cache from modules.imdb import IMDbAPI +from modules.letterboxd import LetterboxdAPI from modules.mal import MyAnimeListAPI from modules.mal import MyAnimeListIDList +from modules.omdb import OMDbAPI from modules.plex import PlexAPI from modules.radarr import RadarrAPI from modules.sonarr import SonarrAPI @@ -15,6 +17,7 @@ from modules.tvdb import TVDbAPI from modules.util import Failed from plexapi.exceptions import BadRequest +from plexapi.media import Guid from ruamel import yaml logger = logging.getLogger("Plex Meta Manager") @@ -69,6 +72,7 @@ def replace_attr(all_data, attr, par): if "tautulli" in new_config: new_config["tautulli"] = new_config.pop("tautulli") if "radarr" in new_config: new_config["radarr"] = new_config.pop("radarr") if "sonarr" in new_config: new_config["sonarr"] = new_config.pop("sonarr") + if "omdb" in new_config: new_config["omdb"] = new_config.pop("omdb") if "trakt" in new_config: new_config["trakt"] = new_config.pop("trakt") if "mal" in new_config: new_config["mal"] = new_config.pop("mal") yaml.round_trip_dump(new_config, open(self.config_path, "w"), indent=ind, block_seq_indent=bsi) @@ -170,6 +174,21 @@ def check_for_attribute(data, attribute, parent=None, test_list=None, options="" util.separator() + self.OMDb = None + if "omdb" in self.data: + logger.info("Connecting to OMDb...") + self.omdb = {} + try: + self.omdb["apikey"] = check_for_attribute(self.data, "apikey", parent="omdb", throw=True) + self.OMDb = OMDbAPI(self.omdb, Cache=self.Cache) + except Failed as e: + logger.error(e) + logger.info(f"OMDb Connection {'Failed' if self.OMDb is None else 'Successful'}") + else: + logger.warning("omdb attribute not found") + + util.separator() + self.Trakt = None if "trakt" in self.data: logger.info("Connecting to Trakt...") @@ -205,9 +224,10 @@ def check_for_attribute(data, attribute, parent=None, test_list=None, options="" else: logger.warning("mal attribute not found") - self.TVDb = TVDbAPI(Cache=self.Cache, TMDb=self.TMDb, Trakt=self.Trakt) - self.IMDb = IMDbAPI(Cache=self.Cache, TMDb=self.TMDb, Trakt=self.Trakt, TVDb=self.TVDb) if self.TMDb or self.Trakt else None - self.AniDB = AniDBAPI(Cache=self.Cache, TMDb=self.TMDb, Trakt=self.Trakt) + self.TVDb = TVDbAPI(self) + self.IMDb = IMDbAPI(self) + self.AniDB = AniDBAPI(self) + self.Letterboxd = LetterboxdAPI() util.separator() @@ -260,11 +280,39 @@ def check_for_attribute(data, attribute, parent=None, test_list=None, options="" if params["asset_directory"] is None: logger.warning("Config Warning: Assets will not be used asset_directory attribute must be set under config or under this specific Library") - params["sync_mode"] = check_for_attribute(libs[lib], "sync_mode", parent="settings", test_list=["append", "sync"], options=" append (Only Add Items to the Collection)\n sync (Add & Remove Items from the Collection)", default=self.general["sync_mode"], save=False) - params["show_unmanaged"] = check_for_attribute(libs[lib], "show_unmanaged", parent="settings", var_type="bool", default=self.general["show_unmanaged"], save=False) - params["show_filtered"] = check_for_attribute(libs[lib], "show_filtered", parent="settings", var_type="bool", default=self.general["show_filtered"], save=False) - params["show_missing"] = check_for_attribute(libs[lib], "show_missing", parent="settings", var_type="bool", default=self.general["show_missing"], save=False) - params["save_missing"] = check_for_attribute(libs[lib], "save_missing", parent="settings", var_type="bool", default=self.general["save_missing"], save=False) + if "settings" in libs[lib] and libs[lib]["settings"] and "sync_mode" in libs[lib]["settings"]: + params["sync_mode"] = check_for_attribute(libs[lib], "sync_mode", parent="settings", test_list=["append", "sync"], options=" append (Only Add Items to the Collection)\n sync (Add & Remove Items from the Collection)", default=self.general["sync_mode"], do_print=False, save=False) + else: + params["sync_mode"] = check_for_attribute(libs[lib], "sync_mode", test_list=["append", "sync"], options=" append (Only Add Items to the Collection)\n sync (Add & Remove Items from the Collection)", default=self.general["sync_mode"], do_print=False, save=False) + + if "settings" in libs[lib] and libs[lib]["settings"] and "show_unmanaged" in libs[lib]["settings"]: + params["show_unmanaged"] = check_for_attribute(libs[lib], "show_unmanaged", parent="settings", var_type="bool", default=self.general["show_unmanaged"], do_print=False, save=False) + else: + params["show_unmanaged"] = check_for_attribute(libs[lib], "show_unmanaged", var_type="bool", default=self.general["show_unmanaged"], do_print=False, save=False) + + if "settings" in libs[lib] and libs[lib]["settings"] and "show_filtered" in libs[lib]["settings"]: + params["show_filtered"] = check_for_attribute(libs[lib], "show_filtered", parent="settings", var_type="bool", default=self.general["show_filtered"], do_print=False, save=False) + else: + params["show_filtered"] = check_for_attribute(libs[lib], "show_filtered", var_type="bool", default=self.general["show_filtered"], do_print=False, save=False) + + if "settings" in libs[lib] and libs[lib]["settings"] and "show_missing" in libs[lib]["settings"]: + params["show_missing"] = check_for_attribute(libs[lib], "show_missing", parent="settings", var_type="bool", default=self.general["show_missing"], do_print=False, save=False) + else: + params["show_missing"] = check_for_attribute(libs[lib], "show_missing", var_type="bool", default=self.general["show_missing"], do_print=False, save=False) + + if "settings" in libs[lib] and libs[lib]["settings"] and "save_missing" in libs[lib]["settings"]: + params["save_missing"] = check_for_attribute(libs[lib], "save_missing", parent="settings", var_type="bool", default=self.general["save_missing"], do_print=False, save=False) + else: + params["save_missing"] = check_for_attribute(libs[lib], "save_missing", var_type="bool", default=self.general["save_missing"], do_print=False, save=False) + + if "mass_genre_update" in libs[lib] and libs[lib]["mass_genre_update"]: + params["mass_genre_update"] = check_for_attribute(libs[lib], "mass_genre_update", test_list=["tmdb", "omdb"], options=" tmdb (Use TMDb Metadata)\n omdb (Use IMDb Metadata through OMDb)", default_is_none=True, save=False) + else: + params["mass_genre_update"] = None + + if params["mass_genre_update"] == "omdb" and self.OMDb is None: + params["mass_genre_update"] = None + logger.error("Config Error: mass_genre_update cannot be omdb without a successful OMDb Connection") try: params["metadata_path"] = check_for_attribute(libs[lib], "metadata_path", var_type="path", default=os.path.join(default_dir, f"{lib}.yml"), throw=True) @@ -292,7 +340,7 @@ def check_for_attribute(data, attribute, parent=None, test_list=None, options="" radarr_params["add"] = check_for_attribute(libs[lib], "add", parent="radarr", var_type="bool", default=self.general["radarr"]["add"], save=False) radarr_params["search"] = check_for_attribute(libs[lib], "search", parent="radarr", var_type="bool", default=self.general["radarr"]["search"], save=False) radarr_params["tag"] = check_for_attribute(libs[lib], "search", parent="radarr", var_type="lower_list", default=self.general["radarr"]["tag"], default_is_none=True, save=False) - library.add_Radarr(RadarrAPI(self.TMDb, radarr_params)) + library.Radarr = RadarrAPI(self.TMDb, radarr_params) except Failed as e: util.print_multiline(e) logger.info(f"{params['name']} library's Radarr Connection {'Failed' if library.Radarr is None else 'Successful'}") @@ -310,7 +358,7 @@ def check_for_attribute(data, attribute, parent=None, test_list=None, options="" sonarr_params["search"] = check_for_attribute(libs[lib], "search", parent="sonarr", var_type="bool", default=self.general["sonarr"]["search"], save=False) sonarr_params["season_folder"] = check_for_attribute(libs[lib], "season_folder", parent="sonarr", var_type="bool", default=self.general["sonarr"]["season_folder"], save=False) sonarr_params["tag"] = check_for_attribute(libs[lib], "search", parent="sonarr", var_type="lower_list", default=self.general["sonarr"]["tag"], default_is_none=True, save=False) - library.add_Sonarr(SonarrAPI(self.TVDb, sonarr_params, library.Plex.language)) + library.Sonarr = SonarrAPI(self.TVDb, sonarr_params, library.Plex.language) except Failed as e: util.print_multiline(e) logger.info(f"{params['name']} library's Sonarr Connection {'Failed' if library.Sonarr is None else 'Successful'}") @@ -321,7 +369,7 @@ def check_for_attribute(data, attribute, parent=None, test_list=None, options="" try: tautulli_params["url"] = check_for_attribute(libs[lib], "url", parent="tautulli", default=self.general["tautulli"]["url"], req_default=True, save=False) tautulli_params["apikey"] = check_for_attribute(libs[lib], "apikey", parent="tautulli", default=self.general["tautulli"]["apikey"], req_default=True, save=False) - library.add_Tautulli(TautulliAPI(tautulli_params)) + library.Tautulli = TautulliAPI(tautulli_params) except Failed as e: util.print_multiline(e) logger.info(f"{params['name']} library's Tautulli Connection {'Failed' if library.Tautulli is None else 'Successful'}") @@ -342,16 +390,19 @@ def update_libraries(self, test, requested_collections): os.environ["PLEXAPI_PLEXAPI_TIMEOUT"] = str(library.timeout) logger.info("") util.separator(f"{library.name} Library") - try: library.update_metadata(self.TMDb, test) - except Failed as e: logger.error(e) + logger.info("") + util.separator(f"Mapping {library.name} Library") + logger.info("") + movie_map, show_map = self.map_guids(library) + if not test: + if library.mass_genre_update: + self.mass_metadata(library, movie_map, show_map) + try: library.update_metadata(self.TMDb, test) + except Failed as e: logger.error(e) logger.info("") util.separator(f"{library.name} Library {'Test ' if test else ''}Collections") collections = {c: library.collections[c] for c in util.get_list(requested_collections) if c in library.collections} if requested_collections else library.collections if collections: - logger.info("") - util.separator(f"Mapping {library.name} Library") - logger.info("") - movie_map, show_map = self.map_guids(library) for c in collections: if test and ("test" not in collections[c] or collections[c]["test"] is not True): no_template_test = True @@ -475,7 +526,119 @@ def update_libraries(self, test, requested_collections): except Failed as e: util.print_multiline(e, error=True) continue - builder.run_collections_again(library, collection_obj, movie_map, show_map) + builder.run_collections_again(collection_obj, movie_map, show_map) + + def convert_from_imdb(self, imdb_id, language): + update_tmdb = False + update_tvdb = False + if self.Cache: + tmdb_id, tvdb_id = self.Cache.get_ids_from_imdb(imdb_id) + update_tmdb = False + if not tmdb_id: + tmdb_id, update_tmdb = self.Cache.get_tmdb_from_imdb(imdb_id) + if update_tmdb: + tmdb_id = None + update_tvdb = False + if not tvdb_id: + tvdb_id, update_tvdb = self.Cache.get_tvdb_from_imdb(imdb_id) + if update_tvdb: + tvdb_id = None + else: + tmdb_id = None + tvdb_id = None + from_cache = tmdb_id is not None or tvdb_id is not None + + if not tmdb_id and not tvdb_id and self.TMDb: + try: + tmdb_id = self.TMDb.convert_imdb_to_tmdb(imdb_id) + except Failed: + pass + if not tmdb_id and not tvdb_id and self.TMDb: + try: + tvdb_id = self.TMDb.convert_imdb_to_tvdb(imdb_id) + except Failed: + pass + if not tmdb_id and not tvdb_id and self.Trakt: + try: + tmdb_id = self.Trakt.convert_imdb_to_tmdb(imdb_id) + except Failed: + pass + if not tmdb_id and not tvdb_id and self.Trakt: + try: + tvdb_id = self.Trakt.convert_imdb_to_tvdb(imdb_id) + except Failed: + pass + try: + if tmdb_id and not from_cache: self.TMDb.get_movie(tmdb_id) + except Failed: tmdb_id = None + try: + if tvdb_id and not from_cache: self.TVDb.get_series(language, tvdb_id) + except Failed: tvdb_id = None + if not tmdb_id and not tvdb_id: raise Failed(f"IMDb Error: No TMDb ID or TVDb ID found for IMDb: {imdb_id}") + if self.Cache: + if tmdb_id and update_tmdb is not False: + self.Cache.update_imdb("movie", update_tmdb, imdb_id, tmdb_id) + if tvdb_id and update_tvdb is not False: + self.Cache.update_imdb("show", update_tvdb, imdb_id, tvdb_id) + return tmdb_id, tvdb_id + + def mass_metadata(self, library, movie_map, show_map): + length = 0 + logger.info("") + util.separator(f"Mass Editing {'Movie' if library.is_movie else 'Show'} Library: {library.name}") + logger.info("") + items = library.Plex.all() + for i, item in enumerate(items, 1): + length = util.print_return(length, f"Processing: {i}/{len(items)} {item.title}") + ids = {} + if self.Cache: + ids, expired = self.Cache.get_ids("movie" if library.is_movie else "show", plex_guid=item.guid) + elif library.is_movie: + for tmdb in movie_map: + if movie_map[tmdb] == item.ratingKey: + ids["tmdb"] = tmdb + break + else: + for tvdb in show_map: + if show_map[tvdb] == item.ratingKey: + ids["tvdb"] = tvdb + break + + if library.mass_genre_update: + if library.mass_genre_update == "tmdb": + if "tmdb" not in ids: + util.print_end(length, f"{item.title[:25]:<25} | No TMDb for Guid: {item.guid}") + continue + try: + tmdb_item = self.TMDb.get_movie(ids["tmdb"]) if library.is_movie else self.TMDb.get_show(ids["tmdb"]) + except Failed as e: + util.print_end(length, str(e)) + continue + new_genres = [genre.name for genre in tmdb_item.genres] + elif library.mass_genre_update == "omdb": + if self.OMDb.limit is True: + break + if "imdb" not in ids: + util.print_end(length, f"{item.title[:25]:<25} | No IMDb for Guid: {item.guid}") + continue + try: + omdb_item = self.OMDb.get_omdb(ids["imdb"]) + except Failed as e: + util.print_end(length, str(e)) + continue + new_genres = omdb_item.genres + else: + raise Failed + item_genres = [genre.tag for genre in item.genres] + display_str = "" + for genre in (g for g in item_genres if g not in new_genres): + item.removeGenre(genre) + display_str += f"{', ' if len(display_str) > 0 else ''}-{genre}" + for genre in (g for g in new_genres if g not in item_genres): + item.addGenre(genre) + display_str += f"{', ' if len(display_str) > 0 else ''}+{genre}" + if len(display_str) > 0: + util.print_end(length, f"{item.title[:25]:<25} | Genres | {display_str}") def map_guids(self, library): movie_map = {} @@ -521,11 +684,18 @@ def get_id(self, item, library, length): item_type = guid.scheme.split(".")[-1] check_id = guid.netloc - if item_type == "plex" and library.is_movie: + if item_type == "plex" and check_id == "movie": for guid_tag in item.guids: url_parsed = requests.utils.urlparse(guid_tag.id) if url_parsed.scheme == "tmdb": tmdb_id = int(url_parsed.netloc) elif url_parsed.scheme == "imdb": imdb_id = url_parsed.netloc + elif item_type == "plex" and check_id == "show": + item.reload() + for guid_tag in item.findItems(item._data, Guid): + url_parsed = requests.utils.urlparse(guid_tag.id) + if url_parsed.scheme == "tvdb": tvdb_id = int(url_parsed.netloc) + elif url_parsed.scheme == "imdb": imdb_id = url_parsed.netloc + elif url_parsed.scheme == "tmdb": tmdb_id = int(url_parsed.netloc) elif item_type == "imdb": imdb_id = check_id elif item_type == "thetvdb": tvdb_id = int(check_id) elif item_type == "themoviedb": tmdb_id = int(check_id) @@ -620,7 +790,7 @@ def get_id(self, item, library, length): elif id_name and api_name: error_message = f"Unable to convert {id_name} to {service_name} using {api_name}" elif id_name: error_message = f"Configure TMDb or Trakt to covert {id_name} to {service_name}" else: error_message = f"No ID to convert to {service_name}" - if self.Cache and (tmdb_id and library.is_movie) or ((tvdb_id or ((anidb_id or mal_id) and tmdb_id)) and library.is_show): + if self.Cache and ((tmdb_id and library.is_movie) or ((tvdb_id or ((anidb_id or mal_id) and tmdb_id)) and library.is_show)): if isinstance(tmdb_id, list): for i in range(len(tmdb_id)): util.print_end(length, f"Cache | {'^' if expired is True else '+'} | {item.guid:<46} | {tmdb_id[i] if tmdb_id[i] else 'None':<6} | {imdb_id[i] if imdb_id[i] else 'None':<10} | {tvdb_id if tvdb_id else 'None':<6} | {anidb_id if anidb_id else 'None':<5} | {mal_id if mal_id else 'None':<5} | {item.title}") diff --git a/modules/imdb.py b/modules/imdb.py index ba17d336a..48fadf6fb 100644 --- a/modules/imdb.py +++ b/modules/imdb.py @@ -7,23 +7,22 @@ logger = logging.getLogger("Plex Meta Manager") class IMDbAPI: - def __init__(self, Cache=None, TMDb=None, Trakt=None, TVDb=None): - if TMDb is None and Trakt is None: - raise Failed("IMDb Error: IMDb requires either TMDb or Trakt") - self.Cache = Cache - self.TMDb = TMDb - self.Trakt = Trakt - self.TVDb = TVDb + def __init__(self, config): + self.config = config + self.urls = { + "list": "https://www.imdb.com/list/ls", + "search": "https://www.imdb.com/search/title/?" + } def get_imdb_ids_from_url(self, imdb_url, language, limit): imdb_url = imdb_url.strip() - if not imdb_url.startswith("https://www.imdb.com/list/ls") and not imdb_url.startswith("https://www.imdb.com/search/title/?"): - raise Failed(f"IMDb Error: {imdb_url} must begin with either:\n| https://www.imdb.com/list/ls (For Lists)\n| https://www.imdb.com/search/title/? (For Searches)") + if not imdb_url.startswith(self.urls["list"]) and not imdb_url.startswith(self.urls["search"]): + raise Failed(f"IMDb Error: {imdb_url} must begin with either:\n| {self.urls['list']} (For Lists)\n| {self.urls['search']} (For Searches)") - if imdb_url.startswith("https://www.imdb.com/list/ls"): + if imdb_url.startswith(self.urls["list"]): try: list_id = re.search("(\\d+)", str(imdb_url)).group(1) except AttributeError: raise Failed(f"IMDb Error: Failed to parse List ID from {imdb_url}") - current_url = f"https://www.imdb.com/search/title/?lists=ls{list_id}" + current_url = f"{self.urls['search']}lists=ls{list_id}" else: current_url = imdb_url header = {"Accept-Language": language} @@ -61,7 +60,7 @@ def get_items(self, method, data, language, status_message=True): if method == "imdb_id": if status_message: logger.info(f"Processing {pretty}: {data}") - tmdb_id, tvdb_id = self.convert_from_imdb(data, language) + tmdb_id, tvdb_id = self.config.convert_from_imdb(data, language) if tmdb_id: movie_ids.append(tmdb_id) if tvdb_id: show_ids.append(tvdb_id) elif method == "imdb_list": @@ -74,7 +73,7 @@ def get_items(self, method, data, language, status_message=True): for i, imdb_id in enumerate(imdb_ids, 1): length = util.print_return(length, f"Converting IMDb ID {i}/{total_ids}") try: - tmdb_id, tvdb_id = self.convert_from_imdb(imdb_id, language) + tmdb_id, tvdb_id = self.config.convert_from_imdb(imdb_id, language) if tmdb_id: movie_ids.append(tmdb_id) if tvdb_id: show_ids.append(tvdb_id) except Failed as e: logger.warning(e) @@ -85,49 +84,3 @@ def get_items(self, method, data, language, status_message=True): logger.debug(f"TMDb IDs Found: {movie_ids}") logger.debug(f"TVDb IDs Found: {show_ids}") return movie_ids, show_ids - - def convert_from_imdb(self, imdb_id, language): - update_tmdb = False - update_tvdb = False - if self.Cache: - tmdb_id, tvdb_id = self.Cache.get_ids_from_imdb(imdb_id) - update_tmdb = False - if not tmdb_id: - tmdb_id, update_tmdb = self.Cache.get_tmdb_from_imdb(imdb_id) - if update_tmdb: - tmdb_id = None - update_tvdb = False - if not tvdb_id: - tvdb_id, update_tvdb = self.Cache.get_tvdb_from_imdb(imdb_id) - if update_tvdb: - tvdb_id = None - else: - tmdb_id = None - tvdb_id = None - from_cache = tmdb_id is not None or tvdb_id is not None - - if not tmdb_id and not tvdb_id and self.TMDb: - try: tmdb_id = self.TMDb.convert_imdb_to_tmdb(imdb_id) - except Failed: pass - if not tmdb_id and not tvdb_id and self.TMDb: - try: tvdb_id = self.TMDb.convert_imdb_to_tvdb(imdb_id) - except Failed: pass - if not tmdb_id and not tvdb_id and self.Trakt: - try: tmdb_id = self.Trakt.convert_imdb_to_tmdb(imdb_id) - except Failed: pass - if not tmdb_id and not tvdb_id and self.Trakt: - try: tvdb_id = self.Trakt.convert_imdb_to_tvdb(imdb_id) - except Failed: pass - try: - if tmdb_id and not from_cache: self.TMDb.get_movie(tmdb_id) - except Failed: tmdb_id = None - try: - if tvdb_id and not from_cache: self.TVDb.get_series(language, tvdb_id=tvdb_id) - except Failed: tvdb_id = None - if not tmdb_id and not tvdb_id: raise Failed(f"IMDb Error: No TMDb ID or TVDb ID found for IMDb: {imdb_id}") - if self.Cache: - if tmdb_id and update_tmdb is not False: - self.Cache.update_imdb("movie", update_tmdb, imdb_id, tmdb_id) - if tvdb_id and update_tvdb is not False: - self.Cache.update_imdb("show", update_tvdb, imdb_id, tvdb_id) - return tmdb_id, tvdb_id diff --git a/modules/letterboxd.py b/modules/letterboxd.py new file mode 100644 index 000000000..16b817ef4 --- /dev/null +++ b/modules/letterboxd.py @@ -0,0 +1,58 @@ +import logging, math, re, requests +from lxml import html +from modules import util +from modules.util import Failed +from retrying import retry + +logger = logging.getLogger("Plex Meta Manager") + +class LetterboxdAPI: + def __init__(self): + self.url = "https://letterboxd.com" + + @retry(stop_max_attempt_number=6, wait_fixed=10000) + def send_request(self, url, language): + return html.fromstring(requests.get(url, header={"Accept-Language": language, "User-Agent": "Mozilla/5.0 x64"}).content) + + def get_list_description(self, list_url, language): + descriptions = self.send_request(list_url, language).xpath("//meta[@property='og:description']/@content") + return descriptions[0] if len(descriptions) > 0 and len(descriptions[0]) > 0 else None + + def parse_list_for_slugs(self, list_url, language): + response = self.send_request(list_url, language) + slugs = response.xpath("//div[@class='poster film-poster really-lazy-load']/@data-film-slug") + next_url = response.xpath("//a[@class='next']/@href") + if len(next_url) > 0: + slugs.extend(self.parse_list_for_slugs(f"{self.url}{next_url[0]}", language)) + return slugs + + def get_tmdb_from_slug(self, slug, language): + return self.get_tmdb(f"{self.url}{slug}", language) + + def get_tmdb(self, letterboxd_url, language): + response = self.send_request(letterboxd_url, language) + ids = response.xpath("//body/@data-tmdb-id") + if len(ids) > 0: + return int(ids[0]) + raise Failed(f"Letterboxd Error: TMDb ID not found at {letterboxd_url}") + + def get_items(self, method, data, language, status_message=True): + pretty = util.pretty_names[method] if method in util.pretty_names else method + movie_ids = [] + if status_message: + logger.info(f"Processing {pretty}: {data}") + slugs = self.parse_list_for_slugs(data, language) + total_slugs = len(slugs) + if total_slugs == 0: + raise Failed(f"Letterboxd Error: No List Items found in {data}") + length = 0 + for i, slug in enumerate(slugs, 1): + length = util.print_return(length, f"Finding TMDb ID {i}/{total_slugs}") + try: + movie_ids.append(self.get_tmdb(slug, language)) + except Failed as e: + logger.error(e) + util.print_end(length, f"Processed {total_slugs} TMDb IDs") + if status_message: + logger.debug(f"TMDb IDs Found: {movie_ids}") + return movie_ids, [] diff --git a/modules/omdb.py b/modules/omdb.py new file mode 100644 index 000000000..ab42d66e0 --- /dev/null +++ b/modules/omdb.py @@ -0,0 +1,60 @@ +import logging, math, re, requests +from lxml import html +from modules import util +from modules.util import Failed +from retrying import retry + +logger = logging.getLogger("Plex Meta Manager") + +class OMDbObj: + def __init__(self, data): + self._data = data + self.title = data["Title"] + try: + self.year = int(data["Year"]) + except (ValueError, TypeError): + self.year = None + self.content_rating = data["Rated"] + self.genres = util.get_list(data["Genre"]) + self.genres_str = data["Genre"] + try: + self.imdb_rating = float(data["imdbRating"]) + except (ValueError, TypeError): + self.imdb_rating = None + try: + self.imdb_votes = int(str(data["imdbVotes"]).replace(',', '')) + except (ValueError, TypeError): + self.imdb_votes = None + try: + self.metacritic_rating = int(data["Metascore"]) + except (ValueError, TypeError): + self.metacritic_rating = None + self.imdb_id = data["imdbID"] + self.type = data["Type"] + +class OMDbAPI: + def __init__(self, params, Cache=None): + self.url = "http://www.omdbapi.com/" + self.apikey = params["apikey"] + self.limit = False + self.Cache = Cache + self.get_omdb("tt0080684") + + @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) + def get_omdb(self, imdb_id): + expired = None + if self.Cache: + omdb_dict, expired = self.Cache.query_omdb(imdb_id) + if omdb_dict and expired is False: + return OMDbObj(omdb_dict) + response = requests.get(self.url, params={"i": imdb_id, "apikey": self.apikey}) + if response.status_code < 400: + omdb = OMDbObj(response.json()) + if self.Cache: + self.Cache.update_omdb(expired, omdb) + return omdb + else: + error = response.json()['Error'] + if error == "Request limit reached!": + self.limit = True + raise Failed(f"OMDb Error: {error}") diff --git a/modules/plex.py b/modules/plex.py index 88c10b890..bf8f10dca 100644 --- a/modules/plex.py +++ b/modules/plex.py @@ -60,20 +60,12 @@ def get_dict(attribute): self.show_filtered = params["show_filtered"] self.show_missing = params["show_missing"] self.save_missing = params["save_missing"] + self.mass_genre_update = params["mass_genre_update"] self.plex = params["plex"] self.timeout = params["plex"]["timeout"] self.missing = {} self.run_again = [] - def add_Radarr(self, Radarr): - self.Radarr = Radarr - - def add_Sonarr(self, Sonarr): - self.Sonarr = Sonarr - - def add_Tautulli(self, Tautulli): - self.Tautulli = Tautulli - @retry(stop_max_attempt_number=6, wait_fixed=10000) def search(self, title, libtype=None, year=None): if libtype is not None and year is not None: return self.Plex.search(title=title, year=year, libtype=libtype) diff --git a/modules/sonarr.py b/modules/sonarr.py index a2c29bd3f..a13c5c278 100644 --- a/modules/sonarr.py +++ b/modules/sonarr.py @@ -57,7 +57,7 @@ def add_tvdb(self, tvdb_ids, tag=None): tag_nums.append(tag_cache[label]) for tvdb_id in tvdb_ids: try: - show = self.tvdb.get_series(self.language, tvdb_id=tvdb_id) + show = self.tvdb.get_series(self.language, tvdb_id) except Failed as e: logger.error(e) continue diff --git a/modules/tmdb.py b/modules/tmdb.py index bdb635e84..076ae6d50 100644 --- a/modules/tmdb.py +++ b/modules/tmdb.py @@ -114,7 +114,10 @@ def get_credits(self, tmdb_id, actor=False, crew=False, director=False, producer if credit.media_type == "movie": movie_ids.append(credit.id) elif credit.media_type == "tv": - show_ids.append(credit.id) + try: + show_ids.append(self.convert_tmdb_to_tvdb(credit.id)) + except Failed as e: + logger.warning(e) for credit in actor_credits.crew: if crew or \ (director and credit.department == "Directing") or \ @@ -123,7 +126,10 @@ def get_credits(self, tmdb_id, actor=False, crew=False, director=False, producer if credit.media_type == "movie": movie_ids.append(credit.id) elif credit.media_type == "tv": - show_ids.append(credit.id) + try: + show_ids.append(self.convert_tmdb_to_tvdb(credit.id)) + except Failed as e: + logger.warning(e) return movie_ids, show_ids def get_pagenation(self, method, amount, is_movie): diff --git a/modules/trakttv.py b/modules/trakttv.py index c82d235c0..2ffcb9cad 100644 --- a/modules/trakttv.py +++ b/modules/trakttv.py @@ -105,10 +105,10 @@ def watchlist(self, data, is_movie): @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) def standard_list(self, data): - try: items = Trakt[requests.utils.urlparse(data).path].items() - except AttributeError: items = None - if items is None: raise Failed("Trakt Error: No List found") - else: return items + try: trakt_list = Trakt[requests.utils.urlparse(data).path].get() + except AttributeError: trakt_list = None + if trakt_list is None: raise Failed("Trakt Error: No List found") + else: return trakt_list def validate_trakt_list(self, values): trakt_values = [] @@ -145,7 +145,7 @@ def get_items(self, method, data, is_movie, status_message=True): logger.info(f"Processing {pretty}: {data} {media_type}{'' if data == 1 else 's'}") else: if method == "trakt_watchlist": trakt_items = self.watchlist(data, is_movie) - elif method == "trakt_list": trakt_items = self.standard_list(data) + elif method == "trakt_list": trakt_items = self.standard_list(data).items() else: raise Failed(f"Trakt Error: Method {method} not supported") if status_message: logger.info(f"Processing {pretty}: {data}") show_ids = [] diff --git a/modules/tvdb.py b/modules/tvdb.py index b635d8596..4d7a5b8a8 100644 --- a/modules/tvdb.py +++ b/modules/tvdb.py @@ -36,6 +36,12 @@ def __init__(self, tvdb_url, language, is_movie, TVDb): results = response.xpath("//div[@class='row hidden-xs hidden-sm']/div/img/@src") self.poster_path = results[0] if len(results) > 0 and len(results[0]) > 0 else None + results = response.xpath("(//h2[@class='mt-4' and text()='Backgrounds']/following::div/a/@href)[1]") + self.background_path = results[0] if len(results) > 0 and len(results[0]) > 0 else None + + results = response.xpath("//div[@class='block']/div[not(@style='display:none')]/p/text()") + self.summary = results[0] if len(results) > 0 and len(results[0]) > 0 else None + tmdb_id = None if is_movie: results = response.xpath("//*[text()='TheMovieDB.com']/@href") @@ -45,7 +51,7 @@ def __init__(self, tvdb_url, language, is_movie, TVDb): if not tmdb_id: results = response.xpath("//*[text()='IMDB']/@href") if len(results) > 0: - try: tmdb_id = TVDb.convert_from_imdb(util.get_id_from_imdb_url(results[0])) + try: tmdb_id, _ = TVDb.config.convert_from_imdb(util.get_id_from_imdb_url(results[0]), language) except Failed as e: logger.error(e) self.tmdb_id = tmdb_id self.tvdb_url = tvdb_url @@ -54,10 +60,8 @@ def __init__(self, tvdb_url, language, is_movie, TVDb): self.TVDb = TVDb class TVDbAPI: - def __init__(self, Cache=None, TMDb=None, Trakt=None): - self.Cache = Cache - self.TMDb = TMDb - self.Trakt = Trakt + def __init__(self, config): + self.config = config self.site_url = "https://www.thetvdb.com" self.alt_site_url = "https://thetvdb.com" self.list_url = f"{self.site_url}/lists/" @@ -69,20 +73,27 @@ def __init__(self, Cache=None, TMDb=None, Trakt=None): self.series_id_url = f"{self.site_url}/dereferrer/series/" self.movie_id_url = f"{self.site_url}/dereferrer/movie/" - def get_series(self, language, tvdb_url=None, tvdb_id=None): - if not tvdb_url and not tvdb_id: - raise Failed("TVDB Error: get_series requires either tvdb_url or tvdb_id") - elif not tvdb_url and tvdb_id: - tvdb_url = f"{self.series_id_url}{tvdb_id}" + def get_movie_or_series(self, language, tvdb_url, is_movie): + return self.get_movie(language, tvdb_url) if is_movie else self.get_series(language, tvdb_url) + + def get_series(self, language, tvdb_url): + try: + tvdb_url = f"{self.series_id_url}{int(tvdb_url)}" + except ValueError: + pass return TVDbObj(tvdb_url, language, False, self) - def get_movie(self, language, tvdb_url=None, tvdb_id=None): - if not tvdb_url and not tvdb_id: - raise Failed("TVDB Error: get_movie requires either tvdb_url or tvdb_id") - elif not tvdb_url and tvdb_id: - tvdb_url = f"{self.movie_id_url}{tvdb_id}" + def get_movie(self, language, tvdb_url): + try: + tvdb_url = f"{self.movie_id_url}{int(tvdb_url)}" + except ValueError: + pass return TVDbObj(tvdb_url, language, True, self) + def get_list_description(self, tvdb_url, language): + description = self.send_request(tvdb_url, language).xpath("//div[@class='block']/div[not(@style='display:none')]/p/text()") + return description[0] if len(description) > 0 and len(description[0]) > 0 else "" + def get_tvdb_ids_from_url(self, tvdb_url, language): show_ids = [] movie_ids = [] @@ -94,11 +105,11 @@ def get_tvdb_ids_from_url(self, tvdb_url, language): title = item.xpath(".//div[@class='col-xs-12 col-sm-9 mt-2']//a/text()")[0] item_url = item.xpath(".//div[@class='col-xs-12 col-sm-9 mt-2']//a/@href")[0] if item_url.startswith("/series/"): - try: show_ids.append(self.get_series(language, tvdb_url=f"{self.site_url}{item_url}").id) + try: show_ids.append(self.get_series(language, f"{self.site_url}{item_url}").id) except Failed as e: logger.error(f"{e} for series {title}") elif item_url.startswith("/movies/"): try: - tmdb_id = self.get_movie(language, tvdb_url=f"{self.site_url}{item_url}").tmdb_id + tmdb_id = self.get_movie(language, f"{self.site_url}{item_url}").tmdb_id if tmdb_id: movie_ids.append(tmdb_id) else: raise Failed(f"TVDb Error: TMDb ID not found from TVDb URL: {tvdb_url}") except Failed as e: @@ -125,11 +136,9 @@ def get_items(self, method, data, language, status_message=True): if status_message: logger.info(f"Processing {pretty}: {data}") if method == "tvdb_show": - try: show_ids.append(self.get_series(language, tvdb_id=int(data)).id) - except ValueError: show_ids.append(self.get_series(language, tvdb_url=data).id) + show_ids.append(self.get_series(language, data).id) elif method == "tvdb_movie": - try: movie_ids.append(self.get_movie(language, tvdb_id=int(data)).id) - except ValueError: movie_ids.append(self.get_movie(language, tvdb_url=data).id) + movie_ids.append(self.get_movie(language, data).id) elif method == "tvdb_list": tmdb_ids, tvdb_ids = self.get_tvdb_ids_from_url(data, language) movie_ids.extend(tmdb_ids) @@ -140,29 +149,3 @@ def get_items(self, method, data, language, status_message=True): logger.debug(f"TMDb IDs Found: {movie_ids}") logger.debug(f"TVDb IDs Found: {show_ids}") return movie_ids, show_ids - - def convert_from_imdb(self, imdb_id): - update = False - if self.Cache: - tmdb_id, tvdb_id = self.Cache.get_ids_from_imdb(imdb_id) - if not tmdb_id: - tmdb_id, update = self.Cache.get_tmdb_from_imdb(imdb_id) - if update: - tmdb_id = None - else: - tmdb_id = None - from_cache = tmdb_id is not None - - if not tmdb_id and self.TMDb: - try: tmdb_id = self.TMDb.convert_imdb_to_tmdb(imdb_id) - except Failed: pass - if not tmdb_id and self.Trakt: - try: tmdb_id = self.Trakt.convert_imdb_to_tmdb(imdb_id) - except Failed: pass - try: - if tmdb_id and not from_cache: self.TMDb.get_movie(tmdb_id) - except Failed: tmdb_id = None - if not tmdb_id: raise Failed(f"TVDb Error: No TMDb ID found for IMDb: {imdb_id}") - if self.Cache and tmdb_id and update is not False: - self.Cache.update_imdb("movie", update, imdb_id, tmdb_id) - return tmdb_id diff --git a/modules/util.py b/modules/util.py index 4ddfc1d2d..9fdcb953f 100644 --- a/modules/util.py +++ b/modules/util.py @@ -97,6 +97,8 @@ def retry_if_not_failed(exception): "anidb_popular": "AniDB Popular", "imdb_list": "IMDb List", "imdb_id": "IMDb ID", + "letterboxd_list": "Letterboxd List", + "letterboxd_list_details": "Letterboxd List", "mal_id": "MyAnimeList ID", "mal_all": "MyAnimeList All", "mal_airing": "MyAnimeList Airing", @@ -144,11 +146,15 @@ def retry_if_not_failed(exception): "tmdb_writer": "TMDb Writer", "tmdb_writer_details": "TMDb Writer", "trakt_list": "Trakt List", + "trakt_list_details": "Trakt List", "trakt_trending": "Trakt Trending", "trakt_watchlist": "Trakt Watchlist", "tvdb_list": "TVDb List", + "tvdb_list_details": "TVDb List", "tvdb_movie": "TVDb Movie", - "tvdb_show": "TVDb Show" + "tvdb_movie_details": "TVDb Movie", + "tvdb_show": "TVDb Show", + "tvdb_show_details": "TVDb Show" } mal_ranked_name = { "mal_all": "all", @@ -214,6 +220,8 @@ def retry_if_not_failed(exception): "anidb_popular", "imdb_list", "imdb_id", + "letterboxd_list", + "letterboxd_list_details", "mal_id", "mal_all", "mal_airing", @@ -259,11 +267,15 @@ def retry_if_not_failed(exception): "tmdb_writer", "tmdb_writer_details", "trakt_list", + "trakt_list_details", "trakt_trending", "trakt_watchlist", "tvdb_list", + "tvdb_list_details", "tvdb_movie", - "tvdb_show" + "tvdb_movie_details", + "tvdb_show", + "tvdb_show_details" ] collectionless_lists = [ "sort_title", "content_rating", @@ -299,6 +311,7 @@ def retry_if_not_failed(exception): "genre", #"genre.not", "producer", #"producer.not", "studio", #"studio.not", + "title", "writer", #"writer.not" "year" #"year.not", ] @@ -306,15 +319,19 @@ def retry_if_not_failed(exception): "tmdb_network", "tmdb_show", "tmdb_show_details", - "tvdb_show" + "tvdb_show", + "tvdb_show_details" ] movie_only_lists = [ + "letterboxd_list", + "letterboxd_list_details", "tmdb_collection", "tmdb_collection_details", "tmdb_movie", "tmdb_movie_details", "tmdb_now_playing", - "tvdb_movie" + "tvdb_movie", + "tvdb_movie_details" ] movie_only_searches = [ "actor", "actor.not", @@ -440,10 +457,10 @@ def retry_if_not_failed(exception): ] all_details = [ "sort_title", "content_rating", - "summary", "tmdb_summary", "tmdb_description", "tmdb_biography", + "summary", "tmdb_summary", "tmdb_description", "tmdb_biography", "tvdb_summary", "tvdb_description", "trakt_description", "letterboxd_description", "collection_mode", "collection_order", - "url_poster", "tmdb_poster", "tmdb_profile", "file_poster", - "url_background", "file_background", + "url_poster", "tmdb_poster", "tmdb_profile", "tvdb_poster", "file_poster", + "url_background", "tmdb_background", "tvdb_background", "file_background", "name_mapping", "add_to_arr", "arr_tag", "label", "show_filtered", "show_missing", "save_missing" ] diff --git a/plex_meta_manager.py b/plex_meta_manager.py index f8475b683..522e56d26 100644 --- a/plex_meta_manager.py +++ b/plex_meta_manager.py @@ -1,7 +1,12 @@ -import argparse, logging, os, re, schedule, sys, time +import argparse, logging, os, re, sys, time from datetime import datetime -from modules import tests, util -from modules.config import Config +try: + import schedule + from modules import tests, util + from modules.config import Config +except ModuleNotFoundError: + print("Error: Requirements are not installed") + sys.exit(0) parser = argparse.ArgumentParser() parser.add_argument("--my-tests", dest="tests", help=argparse.SUPPRESS, action="store_true", default=False) @@ -60,7 +65,7 @@ def fmt_filter(record): logger.info(util.get_centered_text("| __/| | __/> < | | | | __/ || (_| | | | | | (_| | | | | (_| | (_| | __/ | ")) logger.info(util.get_centered_text("|_| |_|\\___/_/\\_\\ |_| |_|\\___|\\__\\__,_| |_| |_|\\__,_|_| |_|\\__,_|\\__, |\\___|_| ")) logger.info(util.get_centered_text(" |___/ ")) -logger.info(util.get_centered_text(" Version: 1.3.0 ")) +logger.info(util.get_centered_text(" Version: 1.4.0 ")) util.separator() if args.tests: