diff --git a/README.md b/README.md index 0d02f4b1..5608bfbf 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 bfec47e6..74bfefa2 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 5597b0e9..b6138fae 100644 --- a/modules/anidb.py +++ b/modules/anidb.py @@ -7,10 +7,8 @@ from retrying import retry 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 @@ class AniDBAPI: 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 @@ class AniDBAPI: 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 e4082b85..b1061add 100644 --- a/modules/builder.py +++ b/modules/builder.py @@ -240,6 +240,14 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 @@ class CollectionBuilder: 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 2be4cd4f..d11f08f3 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 @@ class Cache: 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 @@ class Cache: 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 @@ class Cache: 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 ccedad5d..ad438961 100644 --- a/modules/config.py +++ b/modules/config.py @@ -4,8 +4,10 @@ from modules.anidb import AniDBAPI 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.trakttv import TraktAPI 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 @@ class Config: 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 @@ class Config: 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 @@ class Config: 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 @@ class Config: 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 @@ class Config: 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 @@ class Config: 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 @@ class Config: 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 @@ class Config: 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 @@ class Config: 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 @@ class Config: 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 @@ class Config: 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 ba17d336..48fadf6f 100644 --- a/modules/imdb.py +++ b/modules/imdb.py @@ -7,23 +7,22 @@ from retrying import retry 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 @@ class IMDbAPI: 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 @@ class IMDbAPI: 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 @@ class IMDbAPI: 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 00000000..16b817ef --- /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 00000000..ab42d66e --- /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 88c10b89..bf8f10dc 100644 --- a/modules/plex.py +++ b/modules/plex.py @@ -60,20 +60,12 @@ class PlexAPI: 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 a2c29bd3..a13c5c27 100644 --- a/modules/sonarr.py +++ b/modules/sonarr.py @@ -57,7 +57,7 @@ class SonarrAPI: 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 bdb635e8..076ae6d5 100644 --- a/modules/tmdb.py +++ b/modules/tmdb.py @@ -114,7 +114,10 @@ class TMDbAPI: 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 @@ class TMDbAPI: 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 c82d235c..2ffcb9ca 100644 --- a/modules/trakttv.py +++ b/modules/trakttv.py @@ -105,10 +105,10 @@ class TraktAPI: @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 @@ class TraktAPI: 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 b635d859..4d7a5b8a 100644 --- a/modules/tvdb.py +++ b/modules/tvdb.py @@ -36,6 +36,12 @@ class TVDbObj: 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 @@ class TVDbObj: 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 @@ class TVDbObj: 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 @@ class TVDbAPI: 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 @@ class TVDbAPI: 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 @@ class TVDbAPI: 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 @@ class TVDbAPI: 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 4ddfc1d2..9fdcb953 100644 --- a/modules/util.py +++ b/modules/util.py @@ -97,6 +97,8 @@ pretty_names = { "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 @@ pretty_names = { "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 @@ all_lists = [ "anidb_popular", "imdb_list", "imdb_id", + "letterboxd_list", + "letterboxd_list_details", "mal_id", "mal_all", "mal_airing", @@ -259,11 +267,15 @@ all_lists = [ "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 @@ plex_searches = [ "genre", #"genre.not", "producer", #"producer.not", "studio", #"studio.not", + "title", "writer", #"writer.not" "year" #"year.not", ] @@ -306,15 +319,19 @@ show_only_lists = [ "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 @@ boolean_details = [ ] 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 f8475b68..522e56d2 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 @@ logger.info(util.get_centered_text("| |_) | |/ _ \\ \\/ / | |\\/| |/ _ \\ __/ _` 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: